From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- .agents/skills/pardes-9p/SKILL.md | 168 + .gitignore | 1 + README.md | 148 +- build.zig | 1355 ++--- build/snap.zig | 53 +- docs/9p.typ | 5 + docs/acme-fs.md | 431 -- docs/config.md | 55 +- docs/design.typ | 424 +- docs/detached.md | 359 +- docs/fs.md | 89 + docs/helix-keys.md | 26 +- docs/lsp.md | 623 +-- docs/macos.md | 82 +- docs/registry.typ | 14 +- docs/web.md | 16 +- examples/README.md | 235 - examples/acmefs/clock.py | 163 - examples/acmefs/eventlog | 161 - examples/acmefs/life.py | 350 -- examples/acmefs/pardesctl | 158 - next-steps.txt | 88 +- src/9p.zig | 2166 +------- src/9p_io.zig | 1454 ++++++ src/9p_quic.zig | 557 ++ src/CHANGELOG.md | 2 +- src/acmefs.zig | 3782 -------------- src/allocators.zig | 104 - src/animation.zig | 189 - src/board9p.zig | 874 ---- src/board_memory.zig | 465 -- src/board_pins.zig | 186 - src/builtins.zig | 871 ++-- src/config.zig | 1435 +++--- src/crash.zig | 2 +- src/detached/client.zig | 143 +- src/detached/server.zig | 1845 ++----- src/detached/wire.zig | 243 +- src/dump.zig | 223 +- src/effect_sources.zig | 273 +- src/esp32p4.zig | 11 +- src/esp32p4/app.zig | 5 +- src/esp32p4/input_rescue.zig | 6 +- src/esp32p4/selftest.zig | 10 +- src/esp32p4/uart.zig | 11 +- src/esp32p4_9p.zig | 295 +- src/esp32p4_gpio.zig | 576 +++ src/file_pane.zig | 1059 ---- src/file_watch.zig | 167 +- src/fs.zig | 4677 +++++++++++++++++ src/fs9_client.zig | 695 --- src/fs9_service.zig | 618 --- src/fs_service.zig | 324 -- src/fuse.zig | 2749 ---------- src/gui/gui.zig | 2434 ++++----- src/host.zig | 345 -- src/host_io.zig | 1572 +++++- src/image.zig | 456 +- src/image_pane.zig | 262 - src/layout.zig | 1706 ++++++ src/limits.zig | 237 - src/look.zig | 1472 +----- src/lsp/lsp.zig | 314 +- src/lsp/lsp_client.zig | 156 +- src/lsp/lsp_zls.zig | 138 +- src/lsp_host.zig | 206 - src/macos.zig | 1602 +++--- src/macos/build-e2e.sh | 59 - src/main.zig | 459 +- src/memory.zig | 148 + src/message.zig | 88 - src/modal.zig | 1598 +++--- src/nested.zig | 743 --- src/normal_input.zig | 659 --- src/output_pane.zig | 695 --- src/output_pane_integration_test.zig | 338 -- src/panel_animation.zig | 662 --- src/panes.zig | 7681 +++++++++++++++++++++++++++ src/pardes.zig | 8549 +++++++------------------------ src/pdf.zig | 7 + src/pdf_bridge.c | 35 +- src/pdf_bridge.h | 1 + src/pdf_pane.zig | 2763 ---------- src/pdf_pane_integration_test.zig | 1819 ------- src/petscii.zig | 446 -- src/runtime_config.zig | 579 --- src/selection_pipe.zig | 26 +- src/shell_bin.zig | 567 -- src/source_manifest.zig | 50 - src/syntax.zig | 653 ++- src/temp_file.zig | 84 - src/term_pane.zig | 3525 ------------- src/tty/panel_compositor.zig | 28 +- src/tty/tty.zig | 1362 ++--- src/tutor.txt | 47 +- src/user_config.zig | 188 - src/web.zig | 18 +- test/agent_session.py | 171 + test/agent_session_test.py | 109 + test/e2e_harness.zig | 12 +- test/fs.py | 549 ++ test/fs_bench.zig | 299 +- test/fs_namespace.zig | 333 ++ test/fs_soak.py | 281 + test/history.zig | 502 ++ test/hxcases/parity-waivers.jsonl | 26 +- test/hxcases/regen.sh | 16 - test/hxcases/waivers.jsonl | 12 +- test/hxdiff.zig | 593 ++- test/lspbench.zig | 266 +- test/macos_e2e.swift | 22 +- test/ninep.py | 148 + test/output.zig | 855 ++++ test/panes.zig | 2743 ++++++++++ test/pdf.zig | 1867 +++++++ test/pdf_sections_bench.zig | 6 +- test/perf.zig | 1330 ++++- test/snapshot.zig | 350 +- test/snapshots/acmefs-event.golden | 15 - test/snapshots/acmefs-event.snap | 39 - test/snapshots/acmefs.golden | 30 - test/snapshots/acmefs.snap | 40 - test/snapshots/builtins.golden | 129 +- test/snapshots/builtins.snap | 75 +- test/snapshots/dump.golden | 1 + test/snapshots/dump.snap | 4 +- test/snapshots/leader.golden | 79 +- test/snapshots/leader.snap | 92 +- test/snapshots/load.golden | 2 +- test/snapshots/load.snap | 4 +- test/snapshots/lsp-client.golden | 15 +- test/snapshots/lsp-client.snap | 9 +- test/snapshots/lspdebug.golden | 30 +- test/snapshots/lspdebug.snap | 64 +- test/snapshots/mini.golden | 183 + test/snapshots/mini.snap | 23 + test/snapshots/nested-optout.snap | 28 - test/snapshots/nested.snap | 21 - test/snapshots/ninep.golden | 195 + test/snapshots/ninep.snap | 28 + test/snapshots/shellset.snap | 2 +- test/syntax.zig | 179 + test/unit_profile.zig | 247 + test/unit_profile_test.zig | 236 + test/web-snapshots/README.md | 30 +- test/web-snapshots/source-list.dump.zon | 27 - test/web-snapshots/source-list.golden | 228 - test/web-snapshots/source-list.snap | 42 - test/web-snapshots/touch.dump.zon | 73 + test/web-snapshots/touch.mjs | 99 + test/web_driver_test.mjs | 134 + test/web_snapshot.mjs | 204 +- 152 files changed, 38878 insertions(+), 50017 deletions(-) create mode 100644 .agents/skills/pardes-9p/SKILL.md delete mode 100644 docs/acme-fs.md create mode 100644 docs/fs.md delete mode 100644 examples/README.md delete mode 100755 examples/acmefs/clock.py delete mode 100755 examples/acmefs/eventlog delete mode 100755 examples/acmefs/life.py delete mode 100755 examples/acmefs/pardesctl create mode 100644 src/9p_io.zig create mode 100644 src/9p_quic.zig delete mode 100644 src/acmefs.zig delete mode 100644 src/allocators.zig delete mode 100644 src/animation.zig delete mode 100644 src/board9p.zig delete mode 100644 src/board_memory.zig delete mode 100644 src/board_pins.zig create mode 100644 src/esp32p4_gpio.zig delete mode 100644 src/file_pane.zig create mode 100644 src/fs.zig delete mode 100644 src/fs9_client.zig delete mode 100644 src/fs9_service.zig delete mode 100644 src/fs_service.zig delete mode 100644 src/fuse.zig delete mode 100644 src/host.zig delete mode 100644 src/image_pane.zig create mode 100644 src/layout.zig delete mode 100644 src/limits.zig delete mode 100644 src/lsp_host.zig delete mode 100755 src/macos/build-e2e.sh create mode 100644 src/memory.zig delete mode 100644 src/message.zig delete mode 100644 src/nested.zig delete mode 100644 src/normal_input.zig delete mode 100644 src/output_pane.zig delete mode 100644 src/output_pane_integration_test.zig delete mode 100644 src/panel_animation.zig create mode 100644 src/panes.zig delete mode 100644 src/pdf_pane.zig delete mode 100644 src/pdf_pane_integration_test.zig delete mode 100644 src/petscii.zig delete mode 100644 src/runtime_config.zig delete mode 100644 src/shell_bin.zig delete mode 100644 src/source_manifest.zig delete mode 100644 src/temp_file.zig delete mode 100644 src/term_pane.zig delete mode 100644 src/user_config.zig create mode 100644 test/agent_session.py create mode 100644 test/agent_session_test.py create mode 100644 test/fs.py create mode 100644 test/fs_namespace.zig create mode 100644 test/fs_soak.py create mode 100644 test/history.zig delete mode 100755 test/hxcases/regen.sh create mode 100644 test/ninep.py create mode 100644 test/output.zig create mode 100644 test/panes.zig create mode 100644 test/pdf.zig delete mode 100644 test/snapshots/acmefs-event.golden delete mode 100644 test/snapshots/acmefs-event.snap delete mode 100644 test/snapshots/acmefs.golden delete mode 100644 test/snapshots/acmefs.snap create mode 100644 test/snapshots/mini.golden create mode 100644 test/snapshots/mini.snap create mode 100644 test/snapshots/ninep.golden create mode 100644 test/snapshots/ninep.snap create mode 100644 test/syntax.zig create mode 100644 test/unit_profile.zig create mode 100644 test/unit_profile_test.zig delete mode 100644 test/web-snapshots/source-list.dump.zon delete mode 100644 test/web-snapshots/source-list.golden delete mode 100644 test/web-snapshots/source-list.snap create mode 100644 test/web-snapshots/touch.dump.zon create mode 100644 test/web-snapshots/touch.mjs create mode 100644 test/web_driver_test.mjs diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md new file mode 100644 index 00000000..09dee666 --- /dev/null +++ b/.agents/skills/pardes-9p/SKILL.md @@ -0,0 +1,168 @@ +--- +name: pardes-9p +description: Inspect and drive a running Pardes editor over 9P, 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 through its control filesystem. +--- + +# Pardes over 9P + +Use the existing [Python client](../../../test/ninep.py) for ad hoc interaction +and functional tests. 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. + +## Connect and identify panes + +Every native session opens a Unix socket. Inside a pane, `PARDES_9P` names the +socket and `PARDES_PANE` is that pane's serial. Outside Pardes, find +`pardes-9p-*.sock` under `$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when +that variable is unset. Select the intended session explicitly; do not assume +the newest socket is the right one. Names come from `--9p=name`, the detached +session name, or the process ID. + +```sh +PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' +import sys +from ninep import Client + +with Client(sys.argv[1]) as client: + print(client.read('/self/index').decode(), end='') + print(client.read('/self/listeners').decode(), end='') +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`. + +The first field of each index row is a stable pane serial, not a slot or row +number. The tag starts after five numeric fields; use `row.split(maxsplit=5)` +to preserve spaces. Inspect `/self/pane//tag`, `body`, or directory +entries before choosing a target. Re-read the index after actions that might +open, reuse or close panes. + +Wire paths start with `/self` or `/os`. `/n/self`, `/n/os`, `/n/peer` and +`/virtual` belong to editor Look paths, not the server root. For example, +Look `/virtual/src/pardes.zig` corresponds to reading `/self/src/pardes.zig`. + +## Edit, Look and execute + +For a file or scratch pane, with a connected `client` and a confirmed +`serial`, let `pane = f'/self/pane/{serial}'`. Terminal body writes instead +send child input; truncation does not erase terminal history. + +| Operation | Client call | +|---|---| +| Read text | `client.read(pane + '/body')` | +| Append text | `client.write(pane + '/body', b'text\n')` | +| Replace all text | `client.write(pane + '/body', b'text\n', truncate=True)` | +| Select a byte range | `client.write(pane + '/addr', b'#0,#2')` | +| Replace that range | `client.write(pane + '/data', b'pub fn')` | +| Show the addressed selection | `client.write(pane + '/ctl', b'dot=addr\n')` | +| Look from this pane | `client.write(pane + '/ctl', b'look /virtual/src/pardes.zig:10\n')` | +| Save | `client.write(pane + '/ctl', b'put\n')` | +| Reload | `client.write(pane + '/ctl', b'get\n')` | +| Close, refusing dirty text | `client.write(pane + '/ctl', b'del\n')` | + +`delete` force-closes, discarding unsaved text. `get` replaces edits with file +contents. Use those only when that loss is intended. Reading `/self/new/ctl` +creates a scratch pane and returns its serial as the first field; it is not +an observational read. + +`ctl` is not a builtin interpreter. Execute builtins through a body Exec event +on an owned scratch pane: + +```python +from fs import new_pane, execute + +control = new_pane(client, b'') +execute(client, control, 'Msg 9p-ready') +``` + +`execute` overwrites that pane's body with the command, then writes +`MX0 \n` to its `event` file. Keep the control pane separate +from user text. The same helper can run `Mini path`, `Mount peer dial`, and +`Unmount peer`; commands resolve relative to the control pane's directory. +A successful event write acknowledges dispatch, not completion: inspect the +resulting pane, message or screen for success. + +Opening `addr` resets its range. To inspect the current selection, open +`addr` first, write `addr=dot\n` to `ctl`, then use `read_fid` on that already +open handle. Do not replace that last step with `client.read`, which reopens +and resets it. Address state is shared by the pane, not private to a client. + +## Terminal input and screen observations + +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 inputs go to the child terminal, not Pardes editor key bindings. + +`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. Compare colors +and attributes, not just style-table indices or flattened text. + +Each screen open freezes one frame. A terminal `body` freezes history on its +first read. `client.read` and `client.screen` reopen each time; use repeated +calls for fresh observations. Do not poll a stale handle. Poll a specific +condition with a deadline and a short delay, rather than a fixed long sleep. +For large histories, measure whole-body reads separately from screen polling; +9P write-to-observation timing includes RPC, rendering and polling overhead. + +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; slow readers can lose older data. Holding `event` open +intercepts Look/Exec clicks, so it is not a passive logger. For Look/Exec +records whose offsets identify the intended file/tag text, forward the short +record ` \n`, not the entire report with flags and text. +Expansion/chord reports need explicit handling; terminal-body events can have +empty ranges with text carried only in the report, so short writeback cannot +reproduce them. Read the event implementation before building an interceptor. +Close handles in `finally`; disconnect after a socket timeout. The service +shares four connection slots and 32 screen/terminal-history snapshot handles. + +## 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: + +```sh +PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY' +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'/self/pane/{serial}' + client.write(pane + '/ctl', b'name 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' + control = new_pane(client, b'') + execute(client, control, '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') +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; +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/OS-file access. Use isolated +loopback listeners for tests. For less common control verbs or event details, +read their implementation and tests in [src/fs.zig](../../../src/fs.zig). diff --git a/.gitignore b/.gitignore index 5a2f2881..691cb6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ zig-pkg zig-out* .zig-cache/ .zig-cache/* +test/__pycache__/ assets/MapleMono-NF-Regular.ttf # a failing snapshot writes .actual beside its golden; it is the failure # report, not a source of truth, and eleven had been committed by accident diff --git a/README.md b/README.md index daeac183..1fdb73bb 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ mouse carries meaning — left selects, middle executes, right looks — and everything on screen is text that is equally alive, whether a shell printed it or you typed it. -One program, five thin shells. The core is a library in the way ghostty-vt is a -library: you feed it events, it returns a surface and a list of effects, and it -performs no IO itself. Everything a shell does is translate native input into -`pardes.Event`, render `pardes.Surface`, and perform `pardes.Effect`. +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. + +`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. ## Requirements @@ -17,6 +20,8 @@ Zig **0.16.0** (`build.zig.zon` pins `minimum_zig_version`). Dependencies are 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. ## Build @@ -32,10 +37,8 @@ puts them in `~/.local/bin`. ~/.local/bin/pardes-gui the SDL3 window ``` -Override with `--prefix `. Six development binaries install under -`/dev` so they never land on a `PATH` by accident: `perf`, -`fs-bench`, `lspbench`, `pdf-scroll-bench`, `hxdiff` and the isolated build. -The other steps below build what they need and install nothing. +Override with `--prefix `. Test, benchmark and run steps build what they +need without installing development binaries. ``` pardes --version e.g. pardes 0.0.1 (e61bbb2e86bd) @@ -52,13 +55,15 @@ The version comes from `build.zig.zon`'s `.version`; the commit is read from | `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` | a freestanding wasm core plus vanilla JavaScript, rendered as HTML/CSS | +| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=` | 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 -Dplatform=esp32p4 -Desp32p4-firmware` | firmware for an ESP32-P4: a freestanding riscv32 object driving libvaxis down a UART, in 384 KiB of heap | +| `zig build -Dplatform=esp32p4` | a freestanding riscv32 editor object for ESP32-P4, using a 384 KiB heap | -The board build needs an ESP-IDF checkout for its register headers, and adds -`esp32p4-flash`, `esp32p4-attach`, `esp32p4-run`, `esp32p4-reset`, -`esp32p4-image-size`, `esp32p4-image-check` and `esp32p4-test`. +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`. ## Detached sessions @@ -80,30 +85,129 @@ words. See `docs/detached.md`. ``` zig build unit-test module and shell unit tests +zig build test-build compile the unit-test programs without running them +zig build unit-profile test request time and process memory as JSONL +zig build unit-profile-test profiler timing, failure and timeout checks +zig build core-test core tests without native shell tests +zig build config-test configuration tests without compiling the editor +zig build pane-test pane and namespace integration tests +zig build syntax-test tree-sitter tests without building the editor +zig build syntax deterministic per-byte highlighting snapshots +zig build syntax-bench highlighting latency and allocation counts +zig build perf-test benchmark validation without compiling the editor +zig build lspbench-check require every configured language probe to be correct +zig build lspbench-test language benchmark omission and expectation gates +zig build history-test historical measurement and comparison tests +zig build fs-test real sessions and mounts over 9P +zig build agent-session-test interactive session driver checks over 9P +zig build fs-bench-test filesystem benchmark option checks without the editor +zig build 9p-test freestanding protocol tests +zig build quic-test -Dquic=true optional QUIC transport tests zig build snap scripted input traces against frozen golden grids +zig build snap-driver-test retry evidence, strict failures and fixture isolation zig build hxdiff differential suite against helix's own behaviour +zig build hxdiff-test comparator, allocation and CLI regression checks +zig build hxdiff-live compare against a freshly run hx-harness +zig build hxdiff-update update reference results only after comparison passes zig build hxparity file-pane vs pty-pane editing parity zig build mupdf-check compile, link, render and search docs/design.pdf -zig build web-snap browser touch/LOOK snapshots +zig build web-snap browser highlighting and touch interactions +zig build web-driver-test browser driver timeouts and cleanup zig build web-e2e Chrome-driven DOM end-to-end suite ``` -`snap` and `hxdiff` take `-- --update` and explicit case files respectively. The -benchmark steps — `perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`, +`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. + +`-Dtest-filter=` 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/.json /tmp/pardes-history/.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=`, `HX_HARNESS`, or `hx-harness` on PATH. + ## Documentation -`docs/design.pdf` (from `docs/design.typ`) is the architecture document and the -place to start. It is also a test fixture: `mupdf-check` renders and searches it. +Start with `src/panes.zig`, `src/layout.zig`, and `src/fs.zig` for ownership and +operations, and `src/pardes.zig` for input. `docs/design.typ` is the architecture +sketch; `docs/design.pdf` is a retained rendering/search fixture and may lag it. | 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/acme-fs.md` | the acme control filesystem (`--fs`) | -| `docs/lsp.md` | the in-process ZLS backend | +| `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 | @@ -118,9 +222,9 @@ says which waiver proves it is still open. ## Layout ``` -src/ the core (pardes.zig) and one module per platform +src/ core events (pardes.zig), panes, layout, fs, syntax src/detached/ the wire, the detached core, the frontend client -src/lsp/ the in-process language backend +src/lsp/ ZLS and external language-server backends test/ harnesses, snapshot goldens, helix cases build/ build-time helpers (the snapshot suite) docs/ see above diff --git a/build.zig b/build.zig index f94a4bcf..98ba2901 100644 --- a/build.zig +++ b/build.zig @@ -7,30 +7,29 @@ const grammar_manifest = @import("src/grammar_manifest.zig"); /// option in `shellOptions` for why that word is load-bearing. const zon = @import("build.zig.zon"); -/// `esp32p4` is not a shell in this package at all: it is one freestanding -/// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware -/// package links beside its own `_start`. See the esp32p4 branch below. pub const Platform = enum { tty, gui, web, macos, esp32p4 }; -/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things -/// have to agree about it or the bundle is a lie: the target this build gives -/// the static library, the `-target` the app's swiftc link is given (which is -/// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the -/// plist's LSMinimumSystemVersion. The macos branch below derives the last two -/// from this, so there is one string and no drift. +const PerfBuild = struct { + compiler: []const u8, + target: []const u8, + cpu: []const u8, + driver_optimize: []const u8, + core_optimize: []const u8, + c_optimize: []const u8, + terminal_optimize: []const u8, + platform: []const u8, + grammars: []const u8, + mupdf: bool, + jpx: bool, + tracy: bool, + quic: bool, + theme_animation: bool, + terminal_simd: bool, + prebuilt_shaders: bool, +}; + pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 }; -/// The ZLS the language backend links. It names the commit build.zig.zon pins -/// (0.16.x branch) and is passed BOTH to ZLS's own `-Dversion-string` (its -/// build.zig otherwise shells out to `git describe`, which fails on a fetched -/// package that has no .git) and to `SPC l i`. -/// -/// It is spelled TWICE — here and in `.dependencies.zls.url` — and the comment -/// that used to claim "spelled once" was wrong. The duplication is unavoidable -/// (the semver half exists nowhere in the manifest), so the `comptime` block -/// below makes the two AGREE by construction: a .zon bump that forgets this -/// line is now a build error instead of a `SPC l i` that names a build nobody -/// linked. const zls_version = "0.16.1-dev+3e0d0820"; comptime { @@ -46,12 +45,6 @@ comptime { } pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; -/// The GUI shell's shaders, spelled ONCE. The runtime SPIR-V imports, their -/// EffectCode source imports, and `zig build shaders` all read this list. That -/// last step writes BOTH the bytecode and its exact source snapshot into the -/// tracked shaders/prebuilt/ pair. Each name is `shaders/.glsl`, and the -/// `.vert`/`.frag` in it is also the glslc shader stage — adding a shader is -/// adding a name here plus the @embedFile in src/gui/gui.zig. const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", "overlay.vert", "overlay.frag", @@ -59,75 +52,16 @@ const gui_shaders = [_][]const u8{ "crt.vert", "crt.frag", }; -/// Every GUI shader is also a source import for EffectCode. Live builds import -/// shaders/*.glsl; prebuilt builds import the source snapshot paired with the -/// committed SPIR-V, so the builtin cannot print code other than what produced -/// the bytecode that build executes. pub fn build(b: *std.Build) void { - // `-Dplatform` names ONE shell. Absent, this build makes BOTH native - // shells — the tty cli and the SDL gui — because those two together are - // what installing pardes means, and asking for them one at a time is two - // invocations that a person has to remember are two. - // - // Everything below derives from `platform`, which is the PRIMARY shell: - // the one rooted at `root_mod`, the one `unit-test` runs, and the one the - // snapshot/harness suites drive. `also_gui` adds the second beside it and - // changes nothing about the first. const requested_platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, esp32p4); absent builds the tty cli and the SDL gui together"); const platform = requested_platform orelse .tty; const also_gui = requested_platform == null; - // WHERE A BARE `zig build` PUTS THE BINARIES: `~/.local/bin`, not - // `zig-out/bin`. The default build IS the install — a `pardes` that is not - // on PATH afterwards is one more command to remember — and the two shells - // it makes are exactly the two a person runs. - // - // Two conditions, and the second one is not the obvious one: - // * no shell was NAMED. `-Dplatform=web` keeps writing `zig-out/web`, - // which its docs name, and `-Dplatform=macos` keeps its lib/include - // layout. - // * nothing else has already said where to install: no DESTDIR, no - // `--prefix`, no `--prefix-*dir`. See `prefixIsUntouched`, which also - // records the one spelling it cannot detect. - // - // It does NOT depend on which step was asked for, because build.zig cannot - // know that (build_runner keeps the step names in a local and resolves them - // after `build()` returns). That is why the dev binaries below install to - // `/dev` rather than `/bin`: `zig build perf` redirects the - // prefix too, and a 200 MB Debug benchmark must not land on a PATH. - // - // `resolveInstallPrefix` is what recomputes the derived lib/bin/include - // directories; assigning `install_prefix` alone would leave `exe_dir` - // pointing into zig-out. if (also_gui and prefixIsUntouched(b)) { if (b.graph.environ_map.get("HOME")) |home| { b.resolveInstallPrefix(b.pathJoin(&.{ home, ".local" }), .{}); - // Said out loud, because a build that moves a file somewhere the - // command line did not mention should not be silent about it — and - // because it is the only signal in the one case this cannot detect, - // `--prefix` given as the default path spelled absolutely. std.debug.print("pardes: installing into {s} (override with --prefix)\n", .{b.install_prefix}); } } - // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu - // with the glibc version pinned low, so a binary built on a rolling- - // release host runs on SteamOS — a native build references the host's - // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" - // on the deck. Override with -Dtarget= as usual. - // - // -Dplatform=macos cannot take that default, and the failure is not - // subtle: swiftc links this archive, so a Steam Deck build hands ld64 ELF - // objects inside a GNU archive and the app link dies with "archive member - // '/SYM64/' not a mach-o file". On a Mac it therefore targets the host - // arch at macos_min_version — the same triple build-app.sh gives swiftc, - // so neither half of the app can disagree with the other about how old a - // macOS it supports. Naming the arch rather than leaving it null is - // ghostty's workaround (Config.genericMacOSTarget): a spelled arch - // resolves the CPU model to generic, where a bare native query would bake - // in apple_m2 and everything its LLVM backend has opinions about. - // - // Anywhere else it stays plain native, which is the whole point of the - // Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a - // binary that machine can actually execute. const esp32p4_target: std.Target.Query = .{ .cpu_arch = .riscv32, .os_tag = .freestanding, @@ -142,16 +76,6 @@ pub fn build(b: *std.Build) void { .os_tag = .macos, .os_version_min = .{ .semver = macos_min_version }, } else .{}, - // The P4 firmware target, spelled out here so `-Dplatform=esp32p4` alone is a - // working command line. The CPU FEATURES are part of that spelling and - // are not optional: the object this build emits is linked into an image - // whose other halves are compiled `generic_rv32+m+a+f+c+zicsr+zifencei`, - // and `f` decides the float ABI. Leaving the model implicit produced a - // soft-float object and `ld.lld: cannot link object files with different - // floating-point ABI` — at LINK time in the other repo, far from here. - // Espressif's GCC adds the vendor extensions xesploop/xespv2p1 on top; - // upstream LLVM has neither and ordinary code never emits them, so this - // matches the base ISA the firmware uses exactly. .esp32p4 => esp32p4_target, .tty, .gui, .web => .{ .cpu_arch = .x86_64, @@ -161,13 +85,6 @@ pub fn build(b: *std.Build) void { }, }, }); - // `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the - // documented `-Dplatform=esp32p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and - // silently produced a soft-float object. The features are not a preference here - `f` decides - // the float ABI, and the object is linked into an image whose other halves have it - so esp32p4 takes - // the pinned query whatever was asked for. `-Dtarget` stays accepted, and the check further down - // still rejects anything that is not riscv32-freestanding, so a wrong `-Dtarget` is an error - // rather than something quietly ignored. const target = if (platform == .esp32p4) b.resolveTargetQuery(esp32p4_target) else requested_target; const requested_optimize = b.standardOptimizeOption(.{}); const static = b.option(bool, "static", "statically link") orelse false; @@ -180,78 +97,42 @@ pub fn build(b: *std.Build) void { &.{}; const is_web = platform == .web; const is_esp32p4 = platform == .esp32p4; - // Platforms with no host libc: the browser and the P4 firmware. Every - // dependency below that exists only because a target links libc — the - // image decoder, ZLS, ghostty's C++ simd, MuPDF — is off for both, and the - // reason is freestanding-ness rather than the browser. const freestanding_core = is_web or is_esp32p4; + const enable_quic = b.option(bool, "quic", "optional 9P-over-QUIC transport using system OpenSSL 3.6+ (default off)") orelse false; + if (enable_quic and freestanding_core) @panic("-Dquic=true needs a native platform"); + const ninep_options = b.addOptions(); + ninep_options.addOption(bool, "quic", enable_quic); const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/esp32p4 off; -Dmupdf=false disables)") orelse !freestanding_core; - // JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per - // page, so without this MuPDF decodes nothing and every page comes back - // blank. On by default — a viewer that cannot open scans is the more - // surprising default — and a switch at all because it is 31 files of - // third-party C parsing untrusted input. See the OPENJPEG block in - // mupdf.zig. const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true; const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding; const is_esp32p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding; - // wasm: size is the budget. - // - // esp32p4: Debug is not a supported mode, and `-Doptimize` defaulting to it made the naive - // `zig build -Dplatform=esp32p4` produce an object that cannot run. Debug wraps every tier in - // `allocators.zig` in a `DebugAllocator`, whose metadata is page-granular; the board hands the - // editor a 384 KiB heap and one 4 KiB page per size class does not fit in it, so the image - // links and then dies in `Pardes.init`. ReleaseFast rather than ReleaseSmall because it was - // measured on the die and not chosen: against ReleaseSmall it is 13% off the fixed - // per-keystroke cost and 36% off the per-character cost, for 35% more flash on a partition - // that is 39% used. See experiments/report.typ. An explicit `-Doptimize=` still wins, so - // ReleaseSmall remains one flag away when flash matters more than latency. const optimize = if (is_web) .ReleaseSmall else if (is_esp32p4_target and requested_optimize == .Debug) .ReleaseFast else requested_optimize; - // The vendored C is never what we are debugging, and at -O0 it dominates - // the app: 90% of a Debug startup is tree-sitter's query analyser - // (perf: ts_query__perform_analysis + ts_lookahead_iterator__next), and - // stb_image decodes at a crawl. Building the C optimized whatever the Zig - // mode takes a Debug boot from ~710ms to ~210ms — the same treatment - // ghostty's simdutf/highway already get here. Zig code keeps its mode. + const test_build = b.step("test-build", "compile unit-test programs without running them"); + const test_nonce: ?*std.Build.Module = if (b.option(bool, "test-rebuild", "force fresh Zig test compilation while retaining cached C dependencies") orelse false) blk: { + const files = b.addTempFiles(); + const source = files.add("test_nonce.zig", b.fmt("pub const nonce = {d};\n", .{std.Io.Clock.awake.now(b.graph.io).nanoseconds})); + break :blk b.createModule(.{ .root_source_file = source, .target = target, .optimize = optimize }); + } else null; + if (freestanding_core) test_build.dependOn(&b.addFail("test-build needs a native platform").step); + const unit_profile = b.addExecutable(.{ + .name = "pardes-unit-profile", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/unit_profile.zig"), + }), + }); const c_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize; - // The browser keeps its useful default grammar without acquiring a host - // libc contract: Tree-sitter and the generated Zig parser are linked into - // the freestanding module against src/web/libc's tiny in-module shim. const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_esp32p4) .disabled else .full; const requested_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full"); const tree_sitter_grammars = requested_grammars orelse default_grammars; const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout"); - // Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on - // the machine that built it and needs no keychain; a Developer ID here is - // what makes one launchable on someone else's. See the macos branch below. const macos_identity = b.option([]const u8, "macos-identity", "codesigning identity for pardes.app (default: ad-hoc)") orelse "-"; - // Shader compilation is the one build input that needs a tool nothing else - // here needs: glslc, which ships with the Vulkan SDK / shaderc and is not - // on a stock machine. It is also the input that changes least often, so - // -Dprebuilt-shaders decouples the two: the SPIR-V compiled from - // shaders/*.glsl and its exact GLSL snapshot are committed together under - // shaders/prebuilt/, and this flag embeds that pair instead of shelling - // out. `-Dplatform=gui` then builds with nothing but a C toolchain. - // - // Off by default when a shell was NAMED, because it trades a dependency for - // a freshness problem: with the flag on, the live .glsl sources are not - // build inputs, so editing one changes neither runtime bytecode nor - // EffectCode until someone runs `zig build shaders` (see below). Somebody - // who typed `-Dplatform=gui` is working on the gui and wants the shaders - // that are actually in the tree. - // - // ON by default for the bare `zig build`, which is a different question - // with a different right answer. That build makes the gui BESIDE the cli, - // for a person who asked for pardes rather than for a graphics toolchain, - // and compiling shaders live would make `zig build` fail on any machine - // without a Vulkan SDK — a dependency the cli never needed and that this - // build did not have before the gui joined it. The committed SPIR-V exists - // exactly so that arrangement is possible. const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (default: on for a bare `zig build`, off when -Dplatform names a shell)") orelse also_gui; // The browser shell is a freestanding wasm core plus ordinary web files. @@ -270,12 +151,6 @@ pub fn build(b: *std.Build) void { if (!enable_mupdf) pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step); - // The other half of -Dprebuilt-shaders: recompile every shader and update - // BOTH tracked artifacts — SPIR-V plus the exact GLSL EffectCode will print. - // A machine without glslc can then consume the pair without consulting the - // live source. Deliberately independent of -Dplatform: whoever changes a - // shader runs this on a machine that has the compiler and commits the diff - // (`jj diff shaders/prebuilt` says whether the pair drifted). const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)"); const update_shaders = b.addUpdateSourceFiles(); for (gui_shaders) |name| { @@ -311,33 +186,18 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path(switch (platform) { .web => "src/web.zig", .macos => "src/macos.zig", - // esp32p4 roots at its own flat C ABI too, for the same reason web and - // macOS do: the firmware's `_start`, its linker script and its UART - // live in the zig-p4 package, which LINKS the object this emits. .esp32p4 => "src/esp32p4.zig", .tty, .gui => "src/main.zig", }), .link_libc = !freestanding_core, }); - // helix differential harness (test/hxdiff.zig): a second compilation of - // the core, driven headlessly. Mirrors root_mod's wiring for - // everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the - // option modules); imported as "pardes" by the harness exe below. - const hx_core_mod = b.createModule(.{ + const core_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/pardes.zig"), .link_libc = true, }); - // `pardes-isolate`: a THIRD compilation of the same graph whose only - // difference is one comptime bool. It cannot be the same binary with a - // flag, because the point is that the isolated build does not CONTAIN the - // filesystem: look.zig's libc paths compile away, so no runtime mistake - // can reach a disk that a `--flag` build still links. - // - // Only the tty platform has one. gui adds a GPU it would still need, and - // web/macos are libraries whose host owns main(). const isolated_mod: ?*std.Build.Module = if (platform == .tty) b.createModule(.{ .target = target, .optimize = optimize, @@ -345,10 +205,6 @@ pub fn build(b: *std.Build) void { .link_libc = true, }) else null; - // The SECOND shell of a default build: the same src/main.zig, compiled - // with `platform = .gui`. A separate module and not a flag on the first - // one for the reason pardes-isolate is separate — `pardes.platform` is - // comptime, and the gui shell @cImports an SDL the cli must not link. const gui_mod: ?*std.Build.Module = if (also_gui) b.createModule(.{ .target = target, .optimize = optimize, @@ -356,10 +212,6 @@ pub fn build(b: *std.Build) void { .link_libc = true, }) else null; - // Every module that compiles src/pardes.zig, so its wiring is applied once - // per dependency instead of once per module per dependency — each paired - // with the frontend it IS, because that is the single thing their - // `pardes_config` modules are allowed to disagree about. const CoreMod = struct { mod: ?*std.Build.Module, shell: Platform }; var core_mods_buf: [4]*std.Build.Module = undefined; var core_shells_buf: [4]Platform = undefined; @@ -369,7 +221,7 @@ pub fn build(b: *std.Build) void { .{ .mod = root_mod, .shell = platform }, // hxdiff's headless core and pardes-isolate are the primary shell's // configuration compiled two more ways, not two more frontends. - .{ .mod = hx_core_mod, .shell = platform }, + .{ .mod = core_module, .shell = platform }, .{ .mod = isolated_mod, .shell = platform }, .{ .mod = gui_mod, .shell = .gui }, }) |entry| if (entry.mod) |m| { @@ -379,6 +231,36 @@ pub fn build(b: *std.Build) void { }; const core_mods = core_mods_buf[0..core_mods_len]; const core_shells = core_shells_buf[0..core_mods_len]; + for (core_mods) |mod| mod.addOptions("9p_options", ninep_options); + const quic_test_step = b.step("quic-test", "test optional QUIC transport (-Dquic=true)"); + if (enable_quic) { + const header = b.addWriteFiles().add("openssl.h", "#include \n#include \n#include \n"); + const translated = b.addTranslateC(.{ .root_source_file = header, .target = target, .optimize = optimize, .link_libc = true }); + const include_dir = std.mem.trim(u8, b.run(&.{ "pkg-config", "--variable=includedir", "openssl" }), " \t\r\n"); + if (include_dir.len == 0) @panic("OpenSSL pkg-config metadata is missing its include directory"); + translated.addSystemIncludePath(.{ .cwd_relative = include_dir }); + const openssl = translated.createModule(); + const library_dir = std.mem.trim(u8, b.run(&.{ "pkg-config", "--variable=libdir", "openssl" }), " \t\r\n"); + if (library_dir.len == 0) @panic("OpenSSL pkg-config metadata is missing its library directory"); + openssl.addLibraryPath(.{ .cwd_relative = library_dir }); + openssl.linkSystemLibrary("ssl", .{}); + openssl.linkSystemLibrary("crypto", .{}); + for (core_mods) |mod| mod.addImport("openssl", openssl); + const quic_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/9p_quic.zig"), + .link_libc = true, + }); + quic_module.addImport("openssl", openssl); + const tests = b.addTest(.{ .root_module = quic_module, .filters = test_filters }); + if (test_nonce) |nonce| tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&tests.step); + const run_tests = b.addRunArtifact(tests); + run_tests.has_side_effects = true; + quic_test_step.dependOn(&run_tests.step); + if (testMatch(b, unit_profile, test_filters, quic_test_step)) |check| check.addArtifactArg(tests); + } else quic_test_step.dependOn(&b.addFail("quic-test needs -Dquic=true and system OpenSSL 3.6+").step); // The conditional dependencies, hoisted so one `wireCore` call at the end // can name them all. The unconditional ones are ordinary consts below. @@ -389,30 +271,10 @@ pub fn build(b: *std.Build) void { var ghostty_core_vt: ?*std.Build.Module = null; var ts_libs: std.ArrayList(*std.Build.Step.Compile) = .empty; - // THEMES. tools/gen_themes.zig turns each vendored helix .toml / zed .json - // into one .zig file per theme plus a list.zig that imports them all; - // src/pardes.zig folds that list into the ring beside the three it ships. - // - // Straight into the build cache and handed over as a module, rather than - // written back into src/: the output then has no freshness problem to own - // (zig re-runs the generator only when a vendored file changes, and a - // cached run leaves the compile's inputs byte-identical, so `zig build` - // with nothing touched still does nothing), there is nothing to gitignore - // and no stale .zig can survive a deleted source. - // - // The INPUT list is read from the directory here rather than written out, - // so adding a theme is dropping a file in — and each file goes in as a - // content-hashed argument, which is what makes that new file re-run the - // step and nothing else. const theme_gen = b.addExecutable(.{ .name = "pardes-gen-themes", .root_module = b.createModule(.{ .target = b.graph.host, - // Debug on purpose: it runs for ~40ms, and every core module - // imports what it generates, so its compile is the first link of - // every cold build. ReleaseSafe cost 16s of compile to save 47ms of - // run time, and the 226 files it writes are byte-identical either - // way. .optimize = .Debug, .root_source_file = b.path("tools/gen_themes.zig"), }), @@ -430,62 +292,11 @@ pub fn build(b: *std.Build) void { if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); - // The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls - // binary, no JSON-RPC, no protocol. Everything except the browser gets it; - // the web shell does not, because it has no threads and no-ops the - // lsp effect anyway, so paying to compile an analyser it can never call - // would be pure wasm. const zls_backend = !freestanding_core; - // THE BOARD'S GRID, a build option because the right size is a measurement rather than a - // constant, and because two independent things limit it. - // - // LATENCY binds first, and linearly: every frame walks the whole grid, at 0.87 us per cell - // measured on the die. 56x14 is 784 cells and a 3,930 us round trip - 63% more area and 40% more - // width than the 40x12 it replaces, and still inside the 4 ms this port was built to hold. 56x16 - // was tried first and measured 4,029, which is over; 80x24 works and costs 4,809. The full curve - // is in `05-zig-p4/experiments/report.typ`. - // - // MEMORY binds much later, and only since vaxis's two unused grids stopped being allocated: - // 140x42 runs, 160x48 links and then traps, and 200x60 does not link at all - `.bss will not fit - // in region l2mem, overflowed by 76036 bytes`, that being the shell's shadow copy of the grid. const esp32p4_cols = b.option(u16, "esp32p4-cols", "the board's grid width in cells (-Dplatform=esp32p4 only)") orelse 56; const esp32p4_rows = b.option(u16, "esp32p4-rows", "the board's grid height in cells (-Dplatform=esp32p4 only)") orelse 14; - // `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` used to be - // declared here, for the block that linked and flashed the image. That - // block is gone — see "where the FLASHABLE image comes from" below — and so - // are they: an option this build cannot honour is worse than no option, - // because it accepts the flag and then ignores it. All three belong to the - // toolchain repository and are spelled the same way there. - // ANIMATED THEME CHANGES, off on the board because there the animation is not an animation. - // - // A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars - - // from the old colors to the new ones over `animation.transition_steps` display frames, which is - // ten. On a screen that repaints in microseconds that is a short legible fade, and it is the - // reason the transition exists: a palette that teleports reads as a glitch. - // - // On a 115200 serial line a frame is not free. Every one of those ten steps recolors every - // anchored cell, so the shell's diff finds the whole chrome dirty and spends a frame's worth of - // wire on it, ten times over, with nothing else to look at in between. Measured on the die, one - // `NextColor`: - // - // fade on 12,593 bytes 1,097 ms of saturated wire - // fade off 2,425 bytes 215 ms - // - // A second of the editor talking to itself about a color, on the one transport where a second is - // noticeable, for a gradient nobody can see arrive gradually at 11.5 KB/s. - // - // COMPTIME, not a runtime flag: `pardes.ChromeAnimation` selects `animation.Immediate` over - // `animation.Transition`, which leaves `ChromeTheme.interpolate` unreachable and out of the image - // (2,336 bytes of it). A bool tested at runtime would have kept every one of those bytes. - // - // A build option rather than a platform test, because "is a frame expensive" is a property of the - // transport and not of the target: a P4 driven over something faster than a UART would want it on, - // and it is off here only as the default that matches the wire this port actually has. const theme_animation = b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for esp32p4)"); - // Everything `pardes_config` carries that is a property of the BUILD - // rather than of one frontend, gathered into one value so the two options - // modules a default build makes cannot drift apart in any other field. const shell_cfg: ShellConfig = .{ .tree_sitter_grammars = tree_sitter_grammars, .tracy = tracy != null, @@ -498,26 +309,10 @@ pub fn build(b: *std.Build) void { .zig_lib_dir = b.graph.zig_lib_directory.path orelse "", .commit = gitCommit(b), }; - // ONE `pardes_config` per distinct frontend in this build: two when the - // cli and the gui are made together, one otherwise. hxdiff's core and - // pardes-isolate share the primary shell's, being the same frontend. const opts = shellOptions(b, shell_cfg, platform); const gui_opts: ?*std.Build.Step.Options = if (also_gui) shellOptions(b, shell_cfg, .gui) else null; - // NOTE: these are attached to the modules at the BOTTOM of this function, - // after every addImport — the module-import table below is folded out of - // root_mod.import_table and would be empty if we attached them here. if (zls_backend) { - // .target/.optimize are mandatory: createZLSModule bakes them into the - // module it registers, so a mismatch here is a second compilation of - // the whole analyser rather than an error. - // `.target`/`.optimize` are MANDATORY: createZLSModule bakes them in, - // so a module built without them mismatches ours at link time. - // `version-string` is not cosmetic either — ZLS's build.zig shells out - // to `git describe` to name itself, and a package the build system - // fetched is an extracted tarball with no .git, so every single build - // printed a "Failed to run git describe" warning. We pin the commit in - // build.zig.zon, so we already know the answer. const zls_dep = b.dependency("zls", .{ .target = target, .optimize = optimize, @@ -526,14 +321,8 @@ pub fn build(b: *std.Build) void { zls_mod = zls_dep.module("zls"); } - // Tracy zones (src/tracy.zig): compile the client into the binary only when - // -Dtracy= names a Tracy checkout; otherwise every zone is a no-op. - // Sampling/callstacks/system tracing stay off: tracy's symbol worker - // SIGSEGVs on this binary's debug info, and its crash handler then parks - // every thread — the app wedges before the first frame. Zones don't need - // any of it. if (tracy) |tracy_path| { - for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| { + for ([_]*std.Build.Module{ root_mod, core_module }) |mod| { mod.addIncludePath(.{ .cwd_relative = tracy_path }); mod.addCSourceFile(.{ .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) }, @@ -551,15 +340,9 @@ pub fn build(b: *std.Build) void { } } - // tree-sitter: the zig bindings + C runtime, one static lib per grammar, - // and each grammar's highlights.scm slurped at build time into the - // ts_queries options module (codegen consumed by comptime in syntax.zig). if (tree_sitter_grammars != .disabled) { const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = c_optimize }).module("tree_sitter"); if (is_web) { - // zig-tree-sitter carries its C runtime as a linked static library. - // Retarget that library away from libc and put the compatibility - // headers before any unavailable freestanding system headers. const web_libc_include: std.Build.LazyPath = .{ .cwd_relative = b.path("src/web/libc/include").getPath(b), }; @@ -595,14 +378,6 @@ pub fn build(b: *std.Build) void { .root_module = b.createModule(.{ .target = target, .optimize = c_optimize, .link_libc = !is_web }), .linkage = .static, }); - // grammars declare external_scanner_create() with EMPTY PARENS - // (a K&R non-prototype, not (void)): under clang's - // -fsanitize=function the callee's type hash differs from the - // runtime's void*(*)(void) call through the pointer, so the - // first scanner call of ANY parse traps (function_type_mismatch, - // an ud1 blamed on a random inlined line). Uninstrumented - // callees make the runtime's call-site checks skip; the rest - // of UBSan stays live for the grammar code. const ts_cflags = [_][]const u8{"-fno-sanitize=function"}; lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c"), .flags = &ts_cflags }); if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags }); @@ -614,9 +389,6 @@ pub fn build(b: *std.Build) void { ts_queries_opts = ts_queries; } - // zstbi (C stb_image): image pane decode. The freestanding core has no C - // allocator ABI, so its module is a compatible no-decode shim; browser IO - // can grow native image decoding independently of the core. const zstbi_dep = if (!freestanding_core) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null; const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{ .target = target, @@ -624,18 +396,8 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/web/zstbi.zig"), }); - // mvzr: the regex engine behind `s` / `S` (helix select/split on a regex). - // A bytecode VM that compiles a RUNTIME pattern into a fixed-size struct - // with no allocator at all, which is exactly the shape an interactive - // prompt needs — see applySelRegex in src/pardes.zig. const mvzr_mod = b.dependency("mvzr", .{ .target = target, .optimize = optimize }).module("mvzr"); - // MuPDF is the default native PDF engine, but remains a genuinely optional - // dependency: with -Dmupdf=false (and by default on web), the lazy source - // archive is not fetched, compiled, linked, or exposed to runtime code. - // mupdf.zig mirrors the 1.27.0 Makefile source graph with Zig's C compiler - // and deliberately enables only the PDF document handler for this first - // integration. if (enable_mupdf) { if (b.lazyDependency("mupdf", .{})) |mupdf_dep| { const mupdf = mupdf_build.add(b, io, mupdf_dep, .{ @@ -656,20 +418,15 @@ pub fn build(b: *std.Build) void { .flags = &.{"-std=gnu11"}, }); mupdf_core_mod = mupdf_mod; - // Compile the @cImport + exception bridge and exercise the Zig - // wrapper's caller-owned RGBA boundary alongside the C end-to-end - // document probe below. const mupdf_test = b.addTest(.{ .root_module = mupdf_mod }); - mupdf_check.dependOn(&b.addRunArtifact(mupdf_test).step); + const run_mupdf_test = b.addRunArtifact(mupdf_test); + run_mupdf_test.has_side_effects = true; + mupdf_check.dependOn(&run_mupdf_test.step); mupdf_check.dependOn(mupdf_build.addProbe(b, mupdf, .{ .target = target, .pdf = b.path("docs/design.pdf"), })); - // A headless renderer benchmark with its own ReleaseFast wrapper - // module. Reusing root_mod's Debug-mode MuPDF import here would - // benchmark Zig safety checks around optimized C rather than the - // production-cost boundary the scoreboard is meant to expose. const pdf_bench_mod = b.createModule(.{ .target = target, .optimize = .ReleaseFast, @@ -697,10 +454,6 @@ pub fn build(b: *std.Build) void { run_pdf_bench.setCwd(b.path(".")); pdf_bench_step.dependOn(&run_pdf_bench.step); - // Unlike the raster-only scoreboard above, this one drives the - // real core so cached +PdfSections reopen and n/N include their - // production state transitions. Pass -Doptimize=ReleaseFast so - // hx_core_mod and its MuPDF wrapper share the executable's mode. const pdf_sections_bench = b.addExecutable(.{ .name = "pardes-pdf-sections-bench", .root_module = b.createModule(.{ @@ -710,7 +463,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - pdf_sections_bench.root_module.addImport("pardes", hx_core_mod); + pdf_sections_bench.root_module.addImport("pardes", core_module); pdf_sections_bench.root_module.addImport("mupdf", mupdf_mod); const sections_bench_opts = b.addOptions(); sections_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); @@ -720,14 +473,6 @@ pub fn build(b: *std.Build) void { run_pdf_sections_bench.setCwd(b.path(".")); pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step); - // The fling scoreboard. Same real-core wiring as the sections - // bench: what it measures is one shell frame — a whole batch of - // wheel notches through update() followed by a single render() — - // so it needs the production core, not the raster wrapper alone. - // The exe follows -Doptimize with the core rather than pinning - // ReleaseFast: a profile or a crash in the harness itself needs the - // same line numbers and un-inlined frames as one in the core, and a - // scoreboard run passes ReleaseFast anyway (see the file header). const pdf_scroll_bench = b.addExecutable(.{ .name = "pardes-pdf-scroll-bench", .root_module = b.createModule(.{ @@ -737,19 +482,12 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - pdf_scroll_bench.root_module.addImport("pardes", hx_core_mod); + pdf_scroll_bench.root_module.addImport("pardes", core_module); pdf_scroll_bench.root_module.addImport("mupdf", mupdf_mod); const scroll_bench_opts = b.addOptions(); scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize)); pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts); - // Installed, unlike the other two: `perf record` needs a stable - // path and a cache hash is not one. By ITS OWN step, and into - // `/dev` rather than `/bin`: a bare `zig build` - // installs into the user's own bin directory now, and build.zig - // cannot tell which step was asked for, so the only way a 200 MB - // Debug benchmark is kept off a PATH is by never targeting `bin`. - pdf_scroll_bench_step.dependOn(&b.addInstallArtifact(pdf_scroll_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step); const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench); if (b.args) |args| run_pdf_scroll_bench.addArgs(args); run_pdf_scroll_bench.setCwd(b.path(".")); @@ -757,9 +495,6 @@ pub fn build(b: *std.Build) void { } } - // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source - // file in two modules — build a single uucode (our config, unpacked tables) - // and hand it to both. Same trick as the prototype; see uucode_config.zig. const uucode_config = b.path("uucode_config.zig"); const uucode_tables = b.dependency("uucode", .{ .target = target, @@ -773,10 +508,6 @@ pub fn build(b: *std.Build) void { .tables_path = uucode_tables, }).module("uucode"); - // Core grapheme/display-width code uses vaxis.gwidth on every platform. - // The web build consumes only that lazy Zig path; terminal IO remains - // unreachable, while omitting the module makes the shared core fail at - // @import("vaxis") before the browser snapshot can render terminal panes. const vaxis_mod = b.dependency("vaxis", .{ .target = target, .optimize = optimize, @@ -784,46 +515,8 @@ pub fn build(b: *std.Build) void { }).module("vaxis"); var ghostty_vt_for_snap: ?*std.Build.Module = null; - // ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via - // xcrun on darwin targets, so cross-compiling to macOS from elsewhere - // uses the scalar fallback (the same configuration the web shell ships). - // Native/cross builds hand ghostty the real target (it defaults to the - // host otherwise); the web path keeps its original no-target fetch, whose - // zig object never uses ghostty's artifacts. - // NOTE: darwin targets also need two one-line zig-0.16 fixes in the - // pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh - // fetch, or bump the pin once upstream carries them): - // src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint - // var module-local) and posix.mmap prot is now a packed struct - // (.{ .READ = true, .WRITE = true }); - // src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode - // literal 0 must be @as(std.c.mode_t, 0). - // Darwin HOSTS need a third: src/build/GhosttyDist.zig:28 — the dist - // tarball's GTK resources run pkg-config for libadwaita eagerly (panics - // without it); gate that block on `b.graph.host.result.os.tag == .linux`. - // Ghostty's pinned translate-c tarball also 404s now (codeberg dropped - // the archive endpoint) — seed zig-pkg/ from a machine that has it. const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); - // app-runtime none + emit-xcframework off: only the ghostty-vt module is - // consumed, and the defaults otherwise drag ghostty's app graph into the - // build — gtk4 header translation via host pkg-config for linux targets, - // the Xcode app graph (iOS SDK, xcodebuild) on darwin hosts. - // NEVER hand ghostty `.Debug`: that flips its `slow_runtime_safety`, which - // walks the whole PageList after every mutation and PANICS the app on a - // transient state its own next lines repair — `PageList.resizeCols` grows - // rows BEFORE it moves a history viewport pin back into the active area, - // so widening a window whose scrollback holds wrapped lines dies with - // "PageList integrity check failed: ViewportPinInsufficientRows". Those - // checks are a ghostty-development tool (upstream ships them off); Zig's - // own safety checks come from OUR optimize mode and are unaffected, since - // ghostty-vt is a module compiled into this binary. See reflow.snap. const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize; - // The P4 firmware has no terminal panes at all (see `terminal_panes` in - // src/pardes.zig), so src/term_pane.zig's `@import("ghostty-vt")` sits in a - // dead comptime branch and `wireCore` below is handed a null. The - // dependency is therefore not merely unused, it is never REQUESTED: this is - // a lazyDependency, so an esp32p4 build does not need the ghostty package (nor - // its translate-c tarball, nor its simd C++) present at all. const ghostty_dep = if (is_esp32p4) null else b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false }); if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); @@ -850,20 +543,6 @@ pub fn build(b: *std.Build) void { .uucode = uucode_mod, }); - // --- the dependency module map, for `gd` on `@import("vaxis")` --- - // - // ZLS resolves an import string three ways: a relative `.zig` path, `std` - // (via zig_lib_dir), and everything else — which it can only answer by - // running `zig build --build-runner` to learn the module graph. This - // backend sets `zig_exe_path = null` on purpose, so that last branch - // returns nothing and every `@import("")` is a silent miss - // while `std` works perfectly. That asymmetry is the whole bug report. - // - // We do not need the compiler to answer it: THIS FILE *is* the module - // graph. Fold the imports we just wired into a name->root-source table and - // hand it to the backend, which consults it exactly where ZLS gave up. - // Costs one build option and stays correct by construction — a dependency - // that is added or renamed above cannot forget to update it. var module_count: usize = 0; { var it = root_mod.import_table.iterator(); @@ -895,21 +574,11 @@ pub fn build(b: *std.Build) void { } } std.debug.assert(module_index == module_count); - // Both options modules get the same map: the shells' import tables are - // identical at this point, because every import that differs between them - // is an anonymous FILE added below rather than a module. for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| { o.addOption([]const []const u8, "module_names", mod_names); o.addOption([]const []const u8, "module_roots", mod_roots); }; - // The one setting that produces a SECOND executable rather than changing - // this one: its own tiny options module, because duplicating `opts` to - // flip a single bool would be twenty lines that must then agree forever. - // Both are handed out in the loop below rather than module by module: - // every compilation of src/pardes.zig imports it unconditionally (:89), - // and naming the modules one at a time is exactly what left the second - // shell without one. const iso_off = b.addOptions(); iso_off.addOption(bool, "isolated", false); const iso_on = b.addOptions(); @@ -918,9 +587,6 @@ pub fn build(b: *std.Build) void { // Consulted only for a gui shell, so `-Dprebuilt-shaders` alone decides it // here; the platform half of that condition is the branch it sits in. const gui_effect_source_dir = if (prebuilt_shaders) "shaders/prebuilt" else "shaders"; - // Attached after the fold above, for the reason `opts` names, and with the - // embedded FILES: those are bytes rather than modules, and adding them - // earlier would list them in the module map as if they were importable. for (core_mods, core_shells) |mod, shell| { mod.addOptions("pardes_config", if (also_gui and shell == .gui) gui_opts.? else opts); mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off); @@ -931,29 +597,13 @@ pub fn build(b: *std.Build) void { if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), }); - // The virtual filesystem's two repo-root entries: `@embedFile` cannot - // reach outside the module's own directory, so they arrive by name. - // See src/source_manifest.zig. mod.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") }); mod.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") }); } - // SDL3 shell wiring. SDL3 and FreeType are both built from source as - // static libraries; gui.zig @cImports their headers plus the small - // FreeType policy shim, and embeds SPIR-V compiled from GLSL. - // - // The module it lands on is whichever one IS the SDL shell here: the - // second shell of a default build, or the only shell under - // `-Dplatform=gui`. Never both, and never the cli. const gui_shell_mod: ?*std.Build.Module = gui_mod orelse if (platform == .gui) root_mod else null; if (gui_shell_mod) |gm| { - // sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND - // ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment - // a HID gamepad is enumerated — SDL_Init SIGILLs on the deck itself - // (ud2 in SDL_hid_set_nonblocking_REAL); headless boxes never see it. - // SDL3 always statically linked (like deckcap): the gui binary must - // only need system libc/libm — never a shared libSDL3. const sdl_dep = b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static }); if (sdl_dep) |dep| { const sdl_lib = dep.artifact("SDL3"); @@ -966,10 +616,6 @@ pub fn build(b: *std.Build) void { .@"enable-libpng" = false, }); gm.linkLibrary(freetype_dep.artifact("freetype")); - // darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so - // it must pick its vulkan backend via the Vulkan SDK's loader + - // MoltenVK in /usr/local/lib — a path dyld no longer searches for - // bare dlopen names. The rpath restores that lookup. if (target.result.os.tag.isDarwin()) gm.addRPath(.{ .cwd_relative = "/usr/local/lib" }); gm.addIncludePath(b.path("src/gui")); @@ -999,9 +645,6 @@ pub fn build(b: *std.Build) void { wasm.entry = .disabled; wasm.rdynamic = true; wasm.export_memory = true; - // The full grammar tier carries ~48 MiB of generated parse tables. - // Keep the compact default at 32 MiB, but leave enough static address - // space for an explicitly requested all-language build to link. wasm.initial_memory = @as(u64, if (tree_sitter_grammars == .full) 64 else 32) * 1024 * 1024; wasm.max_memory = 512 * 1024 * 1024; web_step.dependOn(&b.addInstallFileWithDir(wasm.getEmittedBin(), .{ .custom = "web" }, "pardes.wasm").step); @@ -1018,84 +661,16 @@ pub fn build(b: *std.Build) void { b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step); b.getInstallStep().dependOn(web_step); } else if (is_esp32p4) { - // ONE freestanding object, exporting the C ABI in src/esp32p4.zig. Not an executable, because - // the firmware's `_start`, its generated linker script and its UART driver all live in the - // zig-p4 package; not a library, because `addLibrary` bundles compiler_rt and the firmware - // already has its own; and not a MODULE exposed through build.zig.zon, which is what this - // was first and is the interesting part. - // - // A dependency in the OTHER DIRECTION was tried and reverted. Nesting this package's - // ~30-package graph under zig-p4's broke every build in that repo, not just the firmware - // one: `std/Build.zig:2091` exceeded its 1000-branch comptime quota through ghostty's - // `SharedDeps.zig:874` `lazyImport`, seven cached tree-sitter versions failed to compile - // because their build.zig uses APIs removed in 0.16, and the fetch materialised 2.6 GB - // across 42,736 files into a repo whose entire claim is that Zig is its only dependency. - // - // THIS direction is free, and that is why build.zig.zon now names `.zig_p4`: that package - // declares no dependencies at all, so it enlarges nothing here, and its `build()` - // early-returns when it is not the root. What did NOT change is the seam. The editor still - // crosses to the firmware as this one object over eight C functions, because bytes are the - // right seam for a serial line and because that is the arrangement the board was measured - // through — see the `-Desp32p4-firmware` block below, which links this exact object. - // - // The object is also the compile probe: rooted at src/esp32p4.zig it drags the whole core through - // the riscv32 backend by actually calling it, so `llvm-size` on the result is a real number - // to hold against the board's 1.5 MiB factory partition. const obj = b.addObject(.{ .name = "pardes-esp32p4", .root_module = root_mod }); b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-esp32p4.o").step); - // ------------------------------------------------- and where the FLASHABLE image comes from - // - // Not from here, and this is the one deliberate asymmetry in the board build. - // `-Dplatform=esp32p4` emits the object above and needs no toolchain at all. Linking that - // object into an image needs the linker script, the app descriptor, the heap, the HAL and - // the flash tooling, all of which live in the `05-zig-p4` checkout — so the image is built - // THERE, by the repository that owns them: - // - // zig build -Dpardes # the console firmware - // zig build -Dpardes -Dapp=/src/esp32p4_9p.zig # the 9P server firmware - // - // both taking `-Dpardes-obj=/zig-out/pardes-esp32p4.o`, which is exactly the file - // installed above. - // - // This build tree used to do it too, under `-Desp32p4-firmware`, by declaring `.zig_p4` as - // a path dependency on that sibling checkout. That cost far more than the duplication was - // worth: `@import` in a build script is resolved when the SCRIPT is compiled and not when - // the branch that needs it is taken, so naming the package at all meant that anyone - // without a `../05-zig-p4` beside their pardes could not build pardes AT ALL — not the - // firmware, not the SDL shell, not the terminal one. `zig build` failed with - // `no module named 'zig_p4'` from a line inside an `if` that was false. - // - // Neither `.lazy = true` nor `b.lazyImport` rescues it: laziness is about FETCHING, and a - // path dependency whose directory is absent is generated as a package with no `build.zig` - // rather than as one marked unavailable, so `lazyImport` reaches a `@compileError` instead - // of returning null. Both were tried. The toolchain has no remote to make it a fetched - // dependency instead. - // - // So the arrangement is the one that repository had already arrived at from its own side, - // where its build.zig records the same lesson: the object is the seam, it crosses by PATH - // and never by package, and each repository builds what it owns the pieces of. web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else if (platform == .macos) { - // The native macOS shell is a static library plus a Swift app: Zig - // keeps the core, the ptys and every effect; Swift owns AppKit and - // draws the cell grid with CoreText. See docs/macos.md. - // - // The Zig half is ordinary POSIX and builds anywhere, which is what - // makes the boundary testable without a Mac — only the bundle needs - // one. Deliberately NOT here: an xcframework, lipo and an Xcode - // project. The first two are for a UNIVERSAL binary and this ships - // arm64; the third is a second build system to keep in step for what - // a plist and a few install steps already do. ghostty's - // src/build/GhosttyXCFramework.zig is the map if that day comes. const header = b.addTranslateC(.{ .root_source_file = b.path("src/macos/pardes.h"), .target = target, .optimize = optimize, }); - // The header is hand-written, so only a test keeps it in step with the - // Zig side. Importing the translated header makes it a checkable - // artifact — see the ABI guard at the bottom of src/macos.zig. root_mod.addImport("pardes.h", header.createModule()); const lib = b.addLibrary(.{ @@ -1103,26 +678,10 @@ pub fn build(b: *std.Build) void { .linkage = .static, .root_module = root_mod, }); - // Swift links this archive directly, so the runtime support Zig would - // otherwise expect from a Zig-linked executable has to travel inside - // it or every build ends in undefined symbols at the swiftc link. lib.bundle_compiler_rt = true; lib.bundle_ubsan_rt = true; - // ...and neither does bundling stop at compiler_rt. addLibrary emits - // ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and - // ghostty-vt's simdutf/highway stay in archives of their own that zig - // would hand a linker it drives itself. swiftc drives this one, is - // given one file, and fails with a page of undefined C++ symbols. So - // everything reachable is folded into a single archive first — this is - // ghostty's CombineArchivesStep, minus the non-Darwin half. - // libtool and ranlib are Apple's, so this needs a Darwin host as well - // as a Darwin target; a cross-build installs the plain archive, which - // is all the Linux dev loop ever links (nothing). const archive: ?std.Build.LazyPath = if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null; - // The one artifact everything downstream links. Named as a step rather - // than folded into the install step so the e2e link below can wait for - // the LIBRARY without also waiting for the app bundle. const install_lib: *std.Build.Step = if (archive) |a| &b.addInstallLibFile(a, "libpardes.a").step else @@ -1130,24 +689,9 @@ pub fn build(b: *std.Build) void { b.getInstallStep().dependOn(install_lib); b.installFile("src/macos/pardes.h", "include/pardes.h"); - // ---- pardes.app ---- - // - // A bundle is a directory with a plist, a binary and an icon in it, so - // it is assembled HERE rather than by a script the build shells out to. - // Every input is a file the graph knows — the archive, the four Swift - // sources, the header, the plist, the icon generator — which is what - // makes the app rebuild when one of them moves and stay untouched when - // none does. The script this replaced took an install PREFIX and - // re-derived its inputs from whatever happened to be sitting in - // zig-out, so it could neither be cached nor be wrong out loud. const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)"); const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)"); if (archive) |lib_archive| { - // The deployment target, spelled once (macos_min_version) and given - // to all three things that have to agree about it: the archive was - // built for it, this triple is what writes LC_BUILD_VERSION — the - // thing dyld actually enforces — and the plist key below is the - // claim Finder reads. Apple spells aarch64 "arm64". const apple_arch: []const u8 = if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch); const triple = b.fmt("{s}-apple-macos{d}.{d}", .{ @@ -1155,24 +699,12 @@ pub fn build(b: *std.Build) void { }); const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }); - // swiftc compiles the shell the way zig compiled the core. Pinning - // -O here meant the ordinary build shipped an optimized shell - // around a Debug core, which costs 5x a frame and reads as "the mac - // backend is slow" rather than "you built Debug": one frame at - // 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a - // ReleaseFast one, 4.1 ms of it in pardes_frame alone. const swift_mode: []const u8 = switch (optimize) { .Debug => "-Onone", .ReleaseSmall => "-Osize", .ReleaseFast, .ReleaseSafe => "-O", }; - // -import-objc-header rather than a module map: the header is - // consumed straight from the source tree, so there is nothing to - // stage and nothing to keep in sync. -lc++ because ghostty-vt pulls - // in simdutf and highway; the Zig side bundles compiler_rt and - // ubsan_rt into the archive above, so the C++ runtime is all this - // link still has to supply. const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" }); link.addFileArg(b.path("src/macos/pardes.h")); link.addArg("-o"); @@ -1200,12 +732,6 @@ pub fn build(b: *std.Build) void { "Metal", }); - // The ICNS is generated, not committed: it is a ten-size container - // macOS reads, and a binary blob in the tree is one nobody can - // review. What IS committed is src/macos/icon.png, the drawing it - // is cut from — passed as a file arg so a new picture invalidates - // the cached icon instead of shipping the old one. Compiled alone - // because the file is top-level code — one file, one module. const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" }); const icon_bin = icon_build.addOutputFileArg("pardes-icon"); icon_build.addFileArg(b.path("src/macos/icon.swift")); @@ -1214,11 +740,6 @@ pub fn build(b: *std.Build) void { icon_run.addFileArg(b.path("src/macos/icon.png")); const icon_dir = icon_run.addOutputDirectoryArg("Resources"); - // One version, two consumers: LSMinimumSystemVersion is stamped - // from the same string the link above enforces, so a bumped - // deployment target cannot leave a stale claim behind. plutil reads - // the committed plist and writes a new one into the cache — the - // source file is never mutated, which is what PlistBuddy did. const plist = b.addSystemCommand(&.{ "plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o", }); @@ -1227,9 +748,6 @@ pub fn build(b: *std.Build) void { const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes"); const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist"); - // Runtime Metal compilation keeps this exact source shared with - // EffectCode's build-time embedding; no generated Swift literal - // or second shader copy can drift from what the app executes. const install_scene_kernel = b.addInstallFileWithDir( b.path("shaders/crt.ci.metal"), .{ .custom = "pardes.app/Contents/Resources" }, @@ -1239,17 +757,6 @@ pub fn build(b: *std.Build) void { // halves the Dock shows the generic blank page. const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns"); - // Gatekeeper. Ad-hoc by default, which is what an arm64 bundle - // needs to launch at all and needs no keychain; -Dmacos-identity= - // names a Developer ID for a bundle that leaves this machine, and - // only then are the hardened runtime and a trusted timestamp worth - // asking for — both are notarization's requirements rather than a - // signature's, and --timestamp on an ad-hoc signature is an error. - // - // It signs the DIRECTORY, so it must follow all four installs: a - // signature taken before the icon lands is a signature the icon - // then breaks. Always runs, because the bundle it edits lives - // outside the cache and zig cannot know what is in it. const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity }); if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" }); sign.addArg(b.getInstallPath(.prefix, "pardes.app")); @@ -1260,10 +767,6 @@ pub fn build(b: *std.Build) void { sign.step.dependOn(&install_scene_kernel.step); app_step.dependOn(&sign.step); - // ...and the thing you hand someone. UDZO is the compressed - // read-only image every mac already knows how to open; the app - // inside it carries the signature made above, which is what - // survives the copy out. const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" }); dmg.addArg(b.getInstallPath(.prefix, "pardes.app")); dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg")); @@ -1271,62 +774,51 @@ pub fn build(b: *std.Build) void { dmg.step.dependOn(&sign.step); dmg_step.dependOn(&dmg.step); - // The app is part of an ORDINARY build rather than a verb to - // remember: `zig build -Dplatform=macos` leaves a launchable, - // signed bundle in zig-out beside the library it was linked from. - // The dmg stays opt-in — it is for handing over, not for running. b.getInstallStep().dependOn(&sign.step); } else { - // The library a bundle links has to BE a Mach-O one, and libtool - // is Apple's. Cross-building the ABI for another host is supported - // and tested (the Linux dev loop in docs/macos.md); assembling a - // bundle out of it is not. const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native"); app_step.dependOn(&why.step); dmg_step.dependOn(&why.step); } - // The offscreen AppKit suite. A second link rather than a flag on the - // app: test scaffolding does not ship in the product, and main.swift's - // top-level code is already an entry point (see src/macos/build-e2e.sh). - // Same two arguments the app's own link uses, because it must be the - // same link — it waits on the LIBRARY rather than the whole install, so - // running the suite does not also assemble and sign a bundle it never - // opens. - const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"}); - e2e.addArg(b.getInstallPath(.prefix, "")); - e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); - e2e.has_side_effects = true; // writes a binary outside the cache - e2e.step.dependOn(install_lib); - const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")}); - // Relative, and pinned to the build root: the scripts and their goldens - // are source, not an install artifact, and the harness chdirs into a - // hermetic /tmp world per script — so it resolves both up front and - // must start somewhere it knows. - run_e2e.setCwd(b.path(".")); - run_e2e.addArg("test/macos-snapshots"); - // `-- --update` reaches the harness this way, exactly as the tty suite - // takes it: regenerating goldens is the same binary with one more flag. - if (b.args) |args| run_e2e.addArgs(args); - run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens - run_e2e.step.dependOn(&e2e.step); const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)"); - if (target.result.os.tag.isDarwin()) - e2e_step.dependOn(&run_e2e.step) - else - e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); + if (archive) |lib_archive| { + const arch = if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch); + const triple = b.fmt("{s}-apple-macos{d}.{d}", .{ arch, macos_min_version.major, macos_min_version.minor }); + const e2e = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-import-objc-header" }); + e2e.addFileArg(b.path("src/macos/pardes.h")); + e2e.addArg("-o"); + const e2e_bin = e2e.addOutputFileArg("pardes-macos-e2e"); + for ([_][]const u8{ + "src/macos/Sources/PardesView.swift", + "src/macos/Sources/ScenePostprocessor.swift", + "src/macos/Sources/FileWatcher.swift", + "src/macos/Sources/AppDelegate.swift", + "test/macos_e2e.swift", + }) |source| e2e.addFileArg(b.path(source)); + e2e.addFileArg(lib_archive); + e2e.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics", "-framework", "CoreImage", "-framework", "Metal" }); + const files = b.addWriteFiles(); + const runnable = files.addCopyFile(e2e_bin, "pardes-macos-e2e"); + _ = files.addCopyFile(b.path("shaders/crt.ci.metal"), "crt.ci.metal"); + const run_e2e = b.addSystemCommand(&.{}); + run_e2e.addFileArg(runnable); + run_e2e.setCwd(b.path(".")); + if (b.args) |args| run_e2e.addArgs(args); + run_e2e.has_side_effects = true; + e2e_step.dependOn(&run_e2e.step); + } else e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin host and target").step); - // Same step name the other native platforms use, because in this - // configuration theirs is not declared. macos.zig imports the core, so - // this one aggregate reaches both the C-ABI guard and the core tests. const unit_step = b.step("unit-test", "run the C-ABI guard and core unit tests"); - unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod, .filters = test_filters })).step); + const unit_tests = b.addTest(.{ .root_module = root_mod, .filters = test_filters }); + if (test_nonce) |nonce| unit_tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&unit_tests.step); + const run_unit = b.addRunArtifact(unit_tests); + run_unit.has_side_effects = true; + unit_step.dependOn(&run_unit.step); + if (testMatch(b, unit_profile, test_filters, unit_step)) |check| check.addArtifactArg(unit_tests); web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else { - // binary name: the native-shaped linux-x86_64 tty build stays `pardes` - // (the snap suite and e2e harness drive zig-out/bin/pardes); the gui - // shell gets its own name so it no longer clobbers the tty binary, - // and any non linux-x86_64 target carries os-arch in the name. var exe_name: []const u8 = if (platform == .gui) "pardes-gui" else "pardes"; if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64) exe_name = b.fmt("{s}-{s}-{s}", .{ exe_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) }); @@ -1336,10 +828,6 @@ pub fn build(b: *std.Build) void { .root_module = root_mod, }); b.installArtifact(exe); - // The gui beside the cli when neither was named. Same source, its own - // module, its own binary name — `pardes-gui`, which is the spelling - // nested.zig's executable-family test already expects to find next to - // `pardes` (see samePardesExecutable). if (gui_mod) |gm| { var gui_name: []const u8 = "pardes-gui"; if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64) @@ -1353,11 +841,6 @@ pub fn build(b: *std.Build) void { } const run = b.addRunArtifact(exe); b.step("run", "descend to the pardes").dependOn(&run.step); - // `pardes-isolate`: the SAME source on the core's own defaults — the - // embedded source filesystem, the in-process clipboard, silent ptys. - // Its host keeps only the terminal it draws on, so nothing is forked - // and there is no filesystem in the binary to reach. A pardes that can - // only read itself. if (isolated_mod) |iso| { const iso_exe = b.addExecutable(.{ .name = "pardes-isolate", @@ -1367,17 +850,11 @@ pub fn build(b: *std.Build) void { const run_iso = b.addRunArtifact(iso_exe); // Something to look at on arrival, read out of the binary itself. if (b.args) |args| run_iso.addArgs(args) else run_iso.addArg("src/pardes.zig"); - // Installed by its own step, like every other binary here that is - // not one of the two shells `zig build` exists to produce. const iso_step = b.step("run-isolated", "descend to a pardes with no host but the terminal"); - iso_step.dependOn(&b.addInstallArtifact(iso_exe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step); iso_step.dependOn(&run_iso.step); } web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); - // snapshot parity suite: scripted input traces in a pty, captured grids - // diffed against the goldens in snapshots/ — the frozen record of parity - // with the prototype this program replaced. See build/snap.zig. snap_build.addTty(b, .{ .exe = exe, .target = target, @@ -1385,11 +862,6 @@ pub fn build(b: *std.Build) void { .ghostty_vt = ghostty_vt_for_snap, }); - // Native image regression harness. TTY builds emulate Kitty over a - // pty and inspect the real APC stream; GUI builds drive PARDES_TEST's - // real SDL GPU readback and inspect source-colored pixels. It is a - // separate explicit step because the GUI arm needs a graphical/GPU - // session, while unit-test remains safe on headless builders. const image_harness = b.addExecutable(.{ .name = "pardes-image-harness", .root_module = b.createModule(.{ @@ -1410,10 +882,6 @@ pub fn build(b: *std.Build) void { run_image_harness.has_side_effects = true; b.step("image-harness", "exercise native Kitty/SDL image rendering end to end").dependOn(&run_image_harness.step); - // The PDF arm uses the same native-pixel observer but a generated, - // searchable two-page document. Keep it a distinct opt-in step: the - // ordinary image harness stays identical when MuPDF is disabled, and - // asking for PDF coverage without the feature gets an explicit error. const pdf_harness_step = b.step("pdf-harness", "exercise continuous PDF rendering, search, and sections navigation end to end"); if (enable_mupdf) { const run_pdf_harness = b.addRunArtifact(image_harness); @@ -1426,10 +894,6 @@ pub fn build(b: *std.Build) void { pdf_harness_step.dependOn(&b.addFail("pdf-harness is unavailable with -Dmupdf=false").step); } - // helix differential harness: drive the core headlessly over - // JSON-Lines cases, one contract result line per case on stdout — - // the pardes half of the pardes-vs-helix diff (helix's hx-harness on - // its pardes-harness branch speaks the same contract). const hxdiff = b.addExecutable(.{ .name = "pardes-hxdiff", .root_module = b.createModule(.{ @@ -1439,54 +903,55 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - hxdiff.root_module.addImport("pardes", hx_core_mod); - // Installed by the steps that run it, not by the default install: the - // two shells are what `zig build` puts in the user's bin directory. - const install_hxdiff = &b.addInstallArtifact(hxdiff, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + hxdiff.root_module.addImport("pardes", core_module); const run_hxdiff = b.addRunArtifact(hxdiff); + run_hxdiff.has_side_effects = true; if (b.args) |args| run_hxdiff.addArgs(args) else { - // no args: run the checked-in differential suite offline — - // cases vs the helix goldens, waivers exempting the documented - // pardes-isms. Nonzero exit on any unwaivered mismatch. run_hxdiff.addArgs(&.{ + "--strict", "test/hxcases/cases.jsonl", "test/hxcases/goldens.jsonl", "test/hxcases/waivers.jsonl", }); run_hxdiff.setCwd(b.path(".")); } - const hxdiff_step = b.step("hxdiff", "run the helix differential suite (-- [goldens.jsonl [waivers.jsonl]])"); - hxdiff_step.dependOn(install_hxdiff); + const hxdiff_step = b.step("hxdiff", "compare Helix results (-- --strict cases reference [waivers]; cases alone emits)"); hxdiff_step.dependOn(&run_hxdiff.step); - // file-vs-pty parity: the SAME harness binary, run in --parity mode. - // Each case runs twice over the same text and keys, once in a file - // pane and once in a pty pane, and the two result lines must be - // identical — the file pane is the oracle, so editing a shell pane - // cannot drift away from editing a document. + const helix_harness = b.option([]const u8, "helix-harness", "path to the reference hx-harness executable") orelse + b.graph.environ_map.get("HX_HARNESS") orelse "hx-harness"; + const reference = b.addSystemCommand(&.{helix_harness}); + reference.addFileArg(b.path("test/hxcases/cases.jsonl")); + reference.has_side_effects = true; + const reference_results = reference.captureStdOut(.{}); + const live_diff = b.addRunArtifact(hxdiff); + live_diff.addArg("--strict"); + live_diff.addFileArg(b.path("test/hxcases/cases.jsonl")); + live_diff.addFileArg(reference_results); + live_diff.addFileArg(b.path("test/hxcases/waivers.jsonl")); + live_diff.has_side_effects = true; + b.step("hxdiff-live", "compare the editor against a freshly run Helix reference").dependOn(&live_diff.step); + const update_reference = b.addUpdateSourceFiles(); + update_reference.addCopyFileToSource(reference_results, "test/hxcases/goldens.jsonl"); + update_reference.step.dependOn(&live_diff.step); + b.step("hxdiff-update", "replace checked-in Helix goldens after the live comparison passes").dependOn(&update_reference.step); + const run_hxparity = b.addRunArtifact(hxdiff); + run_hxparity.has_side_effects = true; run_hxparity.addArg("--parity"); if (b.args) |args| run_hxparity.addArgs(args) else { - // the WHOLE helix corpus plus the editing extras: a parity gate - // that only ran the cases its author wrote could not catch the - // next regression. parity-waivers names the divergences that are - // not editing (pardes bindings, pty viewport geometry, a tab the - // emulator expands), each with its reason. run_hxparity.addArgs(&.{ - "--waivers", "test/hxcases/parity-waivers.jsonl", - "test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl", + "--strict", + "--waivers", + "test/hxcases/parity-waivers.jsonl", + "test/hxcases/cases.jsonl", + "test/hxcases/parity.jsonl", }); run_hxparity.setCwd(b.path(".")); } const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)"); - hxparity_step.dependOn(install_hxdiff); hxparity_step.dependOn(&run_hxparity.step); - // the language-backend scoreboard. ReleaseFast on purpose: the point - // is to compare backends' real cost, and a Debug build measures the - // safety checks of whichever one allocates most. It links the same - // core module as hxdiff, so `lsp.query` here is the one the editor - // runs. const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ @@ -1496,19 +961,21 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - lspbench.root_module.addImport("pardes", hx_core_mod); - const install_lspbench = &b.addInstallArtifact(lspbench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + lspbench.root_module.addImport("pardes", core_module); const run_lspbench = b.addRunArtifact(lspbench); if (b.args) |args| run_lspbench.addArgs(args); run_lspbench.setCwd(b.path(".")); - const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])"); - lspbench_step.dependOn(install_lspbench); + run_lspbench.has_side_effects = true; + const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root])"); lspbench_step.dependOn(&run_lspbench.step); + const run_lspbench_check = b.addRunArtifact(lspbench); + run_lspbench_check.addArg("--check"); + if (b.args) |args| run_lspbench_check.addArgs(args); + run_lspbench_check.setCwd(b.path(".")); + run_lspbench_check.has_side_effects = true; + const lspbench_check_step = b.step("lspbench-check", "require every configured language probe to complete correctly"); + lspbench_check_step.dependOn(&run_lspbench_check.step); - // The client's one-shot probe: the same seam, one query, any - // workspace — how the protocol client is exercised against real - // rust/C/go trees during development without driving the editor. - // Debug is fine: the latency measured is the child server's. const lspprobe = b.addExecutable(.{ .name = "pardes-lspprobe", .root_module = b.createModule(.{ @@ -1518,43 +985,67 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - lspprobe.root_module.addImport("pardes", hx_core_mod); - const install_lspprobe = &b.addInstallArtifact(lspprobe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + lspprobe.root_module.addImport("pardes", core_module); const run_lspprobe = b.addRunArtifact(lspprobe); if (b.args) |args| run_lspprobe.addArgs(args); const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- : [arg] [--reps N])"); - lspprobe_step.dependOn(install_lspprobe); lspprobe_step.dependOn(&run_lspprobe.step); - // the editing scoreboard: one gesture, one file size, one number. - // ReleaseFast for the same reason lspbench is — a Debug build measures - // safety checks, and the question here is what the algorithm costs. - // Same core module again, so the `render` this times is the editor's. + const perf_optimize: std.builtin.OptimizeMode = .ReleaseFast; + const perf_options = b.addOptions(); + const perf_target = std.Target.Query.fromTarget(&target.result); + perf_options.addOption(PerfBuild, "identity", .{ + .compiler = builtin.zig_version_string, + .target = perf_target.zigTriple(b.allocator) catch @panic("out of memory"), + .cpu = perf_target.serializeCpuAlloc(b.allocator) catch @panic("out of memory"), + .driver_optimize = @tagName(perf_optimize), + .core_optimize = @tagName(optimize), + .c_optimize = @tagName(c_optimize), + .terminal_optimize = @tagName(ghostty_optimize), + .platform = @tagName(platform), + .grammars = @tagName(tree_sitter_grammars), + .mupdf = enable_mupdf, + .jpx = enable_mupdf and enable_jpx, + .tracy = tracy != null, + .quic = enable_quic, + .theme_animation = theme_animation orelse (platform != .esp32p4), + .terminal_simd = ghostty_simd, + .prebuilt_shaders = platform == .gui and prebuilt_shaders, + }); const perf = b.addExecutable(.{ .name = "pardes-perf", .root_module = b.createModule(.{ .target = target, - .optimize = .ReleaseFast, + .optimize = perf_optimize, .root_source_file = b.path("test/perf.zig"), .link_libc = true, }), }); - perf.root_module.addImport("pardes", hx_core_mod); - const install_perf = &b.addInstallArtifact(perf, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + perf.root_module.addImport("pardes", core_module); + perf.root_module.addOptions("perf_options", perf_options); const run_perf = b.addRunArtifact(perf); + run_perf.has_side_effects = true; if (b.args) |args| run_perf.addArgs(args); run_perf.setCwd(b.path(".")); - const perf_step = b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])"); - perf_step.dependOn(install_perf); + const perf_step = b.step("perf", "gesture latency or bounded terminal stress (-- [--json] [--reps N] [--base old.json] [--terminal-mib N])"); perf_step.dependOn(&run_perf.step); + const perf_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/perf.zig"), + .link_libc = true, + }), + .filters = test_filters, + }); + perf_tests.root_module.addOptions("perf_options", perf_options); + if (test_nonce) |nonce| perf_tests.root_module.addImport("test_rebuild_nonce", nonce); + const run_perf_test = b.addRunArtifact(perf_tests); + run_perf_test.has_side_effects = true; + const perf_test_step = b.step("perf-test", "test performance options and baselines without compiling the editor"); + perf_test_step.dependOn(&run_perf_test.step); + if (testMatch(b, unit_profile, test_filters, perf_test_step)) |check| check.addArtifactArg(perf_tests); - // The filesystem scoreboard. Same shape as `perf` and for the same - // reason: it drives `acmefs.handle` through the real core, so it wants - // the core module rather than a second compilation of it, and - // ReleaseFast because a Debug run measures safety checks. It takes no - // mount point — there is no FUSE and no thread in it, which is exactly - // the claim the split makes and the thing worth measuring separately - // from the kernel's read/write/wake. const fs_bench = b.addExecutable(.{ .name = "pardes-fs-bench", .root_module = b.createModule(.{ @@ -1564,259 +1055,255 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - fs_bench.root_module.addImport("pardes", hx_core_mod); - const install_fs_bench = &b.addInstallArtifact(fs_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + fs_bench.root_module.addImport("pardes", core_module); const run_fs_bench = b.addRunArtifact(fs_bench); + run_fs_bench.has_side_effects = true; if (b.args) |args| run_fs_bench.addArgs(args); run_fs_bench.setCwd(b.path(".")); - const fs_bench_step = b.step("fs-bench", "acme-fs per-request cost and allocation count (-- [--json] [--reps N])"); - fs_bench_step.dependOn(install_fs_bench); + const fs_bench_step = b.step("fs-bench", "9P filesystem request cost and allocation count (-- [--json] [--reps N])"); fs_bench_step.dependOn(&run_fs_bench.step); + const fs_bench_test = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/fs_bench.zig"), + }), + .filters = test_filters, + }); + const run_fs_bench_test = b.addRunArtifact(fs_bench_test); + run_fs_bench_test.has_side_effects = true; + const fs_bench_test_step = b.step("fs-bench-test", "test filesystem benchmark options without compiling the editor"); + fs_bench_test_step.dependOn(&run_fs_bench_test.step); + if (testMatch(b, unit_profile, test_filters, fs_bench_test_step)) |check| check.addArtifactArg(fs_bench_test); - const unit_step = b.step("unit-test", "run native shell and module unit tests"); - // Secure tempfile creation is native-shell IO, isolated from the core - // and tested as its own libc-linked module. - const temp_file_test = b.addTest(.{ .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/temp_file.zig"), - .link_libc = true, - }) }); - unit_step.dependOn(&b.addRunArtifact(temp_file_test).step); - // Resolving a shell binary and picking its prompt integration is the - // same shape: native-shell IO, no core imports, its own libc-linked - // module. (A test file the core merely re-exported would compile and - // silently never run — zig only collects tests from what it analyses.) - const shell_bin_test = b.addTest(.{ .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/shell_bin.zig"), - .link_libc = true, - }) }); - unit_step.dependOn(&b.addRunArtifact(shell_bin_test).step); - // Nested-instance detection and its socket path: same shape again — - // native-shell IO, no core imports, its own libc-linked module. - const nested_test = b.addTest(.{ .root_module = b.createModule(.{ + const syntax_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/nested.zig"), + .root_source_file = b.path("src/syntax.zig"), .link_libc = true, - }) }); - unit_step.dependOn(&b.addRunArtifact(nested_test).step); - // The LSP protocol client. Its own module for the reason its - // neighbours have, and it earned the comment above the hard way: the - // core DOES import src/lsp/lsp.zig, which imports this file, and that - // is not enough — zig collects tests from a module's ROOT SOURCE FILE - // only, so every test in src/lsp/ compiled and none of them ran. What - // went unnoticed behind that is the whole reason this entry exists: the - // transport asked for SOCK_CLOEXEC, which darwin's socketpair(2) - // rejects, so no language server had ever spawned on macOS. The - // end-to-end cover (test/snapshots/lsp-client.snap) could not catch it - // either — `snap` runs the x86_64-linux target, where the flag is real. - // - // `pardes_config` and `zls` are attached because lsp.zig's `backends` - // names the in-process analyser; this test module compiles the same - // graph the shells do rather than a second, luckier one. - const lsp_client_test = b.addTest(.{ .root_module = blk: { - const m = b.createModule(.{ + }); + syntax_module.addOptions("pardes_config", opts); + wireCore(syntax_module, .{ .tree_sitter = ts_mod, .ts_libs = ts_libs.items, .ts_queries = ts_queries_opts }); + const syntax_runner = b.addExecutable(.{ + .name = "pardes-syntax", + .root_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/lsp/lsp_client.zig"), + .root_source_file = b.path("test/syntax.zig"), .link_libc = true, - }); - m.addOptions("pardes_config", opts); - if (zls_mod) |x| m.addImport("zls", x); - break :blk m; - } }); - unit_step.dependOn(&b.addRunArtifact(lsp_client_test).step); - // The shared LSP host worker. Rooted here for the reason every module - // above it is: zig collects tests from a module's ROOT FILE only, and - // this one is reached through src/pardes.zig by three shells that would - // each have compiled its tests and run none of them. It needs the whole - // core graph because it imports the core — the same wiring hx_core_mod - // gets, in one call, so a dependency added there is not missed here. - const lsp_host_test = b.addTest(.{ .root_module = blk: { - const m = b.createModule(.{ + }), + }); + syntax_runner.root_module.addImport("syntax", syntax_module); + const run_syntax = b.addRunArtifact(syntax_runner); + if (b.args) |args| run_syntax.addArgs(args); + b.step("syntax", "emit deterministic syntax snapshots").dependOn(&run_syntax.step); + const run_syntax_bench = b.addRunArtifact(syntax_runner); + run_syntax_bench.addArg("--bench"); + if (b.args) |args| run_syntax_bench.addArgs(args); + run_syntax_bench.has_side_effects = true; + b.step("syntax-bench", "measure highlighting latency and allocations").dependOn(&run_syntax_bench.step); + const syntax_test = b.addTest(.{ + .root_module = syntax_module, + .filters = if (test_filters.len == 0) &.{"syntax "} else test_filters, + }); + const run_syntax_test = b.addRunArtifact(syntax_test); + run_syntax_test.has_side_effects = true; + const syntax_test_step = b.step("syntax-test", "test syntax correctness without building the editor"); + syntax_test_step.dependOn(&run_syntax_test.step); + if (testMatch(b, unit_profile, test_filters, syntax_test_step)) |check| check.addArtifactArg(syntax_test); + + const run_fs_test = b.addSystemCommand(&.{ "python3", "-B", "test/fs.py" }); + run_fs_test.addArtifactArg(exe); + if (enable_quic) run_fs_test.addArg("--quic"); + run_fs_test.setCwd(b.path(".")); + run_fs_test.has_side_effects = true; + b.step("fs-test", "exercise default 9P service and mounts with an independent client").dependOn(&run_fs_test.step); + + const run_agent_session_test = b.addSystemCommand(&.{ "python3", "-B", "test/agent_session_test.py" }); + run_agent_session_test.addArtifactArg(exe); + if (platform == .gui) run_agent_session_test.addArg("--gui-grid"); + run_agent_session_test.setCwd(b.path(".")); + run_agent_session_test.has_side_effects = true; + b.step("agent-session-test", "test interactive session readiness, input and cleanup through 9P").dependOn(&run_agent_session_test.step); + + const history = b.addExecutable(.{ + .name = "pardes-history", + .root_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/lsp_host.zig"), - .link_libc = true, - }); - m.addOptions("pardes_config", opts); - m.addOptions("pardes_isolation", iso_off); - wireCore(m, .{ - .themes = themes_mod, - .zls = zls_mod, - .tree_sitter = ts_mod, - .ts_libs = ts_libs.items, - .ts_queries = ts_queries_opts, - .zstbi = zstbi_mod, - .mvzr = mvzr_mod, - .mupdf = mupdf_core_mod, - .ghostty_vt = ghostty_core_vt, - .vaxis = vaxis_mod, - .uucode = uucode_mod, - }); - // src/source_manifest.zig embeds these two by name; every core - // compilation gets them beside `pardes_config` (see the core_mods - // loop), and a module rooted inside the core needs them too. - m.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") }); - m.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") }); - break :blk m; - } }); - unit_step.dependOn(&b.addRunArtifact(lsp_host_test).step); - // The 9P2000 codec and its sans-io server. Its own module for the - // reason spelled out above rather than as a convention, and a stronger - // one than its neighbours have: 9p.zig is FREESTANDING — it imports - // `std` and nothing else, so that the same source compiles for - // wasm32- and riscv32-freestanding — and its only host-side importer - // is src/fs9_service.zig, which reaches pardes.zig. Compiling it here - // as its own root is therefore also the check that the freestanding - // promise still holds: NO `link_libc`, and an import of anything - // OS-shaped would fail this step rather than passing quietly inside - // the core's graph. - const ninep_test = b.addTest(.{ .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/9p.zig"), - }) }); - unit_step.dependOn(&b.addRunArtifact(ninep_test).step); - // The board's own 9P tree, and the comptime table that generates it. Its own module for the - // reason its neighbours have: nothing `unit-test` compiles reaches src/board9p.zig — the - // core does not import it, because the whole point of it is that it does NOT import the - // core — so its eight tests would otherwise silently not exist. No `link_libc`: it imports - // `std`, `src/board_pins.zig` and, in its tests only, `ninep`, which is what lets the same - // source serve a real client here and drive `hal.gpio` on the die. - // - // `ninep` used to be injected here as a named module over src/9p.zig. - // It is a plain path import now (`src/board9p.zig` says why), so this - // test needs no module map at all — which is the same property that - // lets the toolchain repository root a firmware image at - // src/esp32p4_9p.zig without being told what to inject. - const board9p_test = b.addTest(.{ .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/board9p.zig"), - }) }); - unit_step.dependOn(&b.addRunArtifact(board9p_test).step); - // The board's input-rescue policy: drain the receiver while spinning on a full transmitter. - // A measured bug — a 200-byte burst typed into a long frame lost 88 bytes on the die — so - // it gets a test that fails without the fix, and it runs HERE rather than only on hardware. - // Its own module for the reason spelled out above rather than as a convention: nothing - // `unit-test` compiles reaches src/esp32p4/, because the firmware root is its own module graph, - // so these five tests would otherwise silently not exist. No `link_libc`, unlike its - // neighbours: input_rescue.zig imports `std` and nothing else, which is exactly what lets - // the same source run against a fake FIFO here and against UART0 on the board. - const esp32p4_rescue_test = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("test/history.zig"), + }), + }); + const run_history = b.addRunArtifact(history); + if (b.args) |args| run_history.addArgs(args); + run_history.has_side_effects = true; + b.step("history", "compare command output and test/build runtime across jj revisions").dependOn(&run_history.step); + + const unit_step = b.step("unit-test", "run native shell and module unit tests"); + const unit_match = testMatch(b, unit_profile, test_filters, unit_step); + const lspbench_tests = b.addTest(.{ .root_module = lspbench.root_module, .filters = test_filters }); + const run_lspbench_tests = b.addRunArtifact(lspbench_tests); + run_lspbench_tests.has_side_effects = true; + const lspbench_test_step = b.step("lspbench-test", "test language benchmark completeness and correctness gates"); + lspbench_test_step.dependOn(&run_lspbench_tests.step); + lspbench_check_step.dependOn(&run_lspbench_tests.step); + if (testMatch(b, unit_profile, test_filters, lspbench_test_step)) |check| check.addArtifactArg(lspbench_tests); + const config_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/esp32p4/input_rescue.zig"), - }) }); - unit_step.dependOn(&b.addRunArtifact(esp32p4_rescue_test).step); - // fonts.zig is the same shape once more, and it needs its own module - // for the reason spelled out above rather than as a convention: the - // core imports it behind `platform == .gui or .macos`, so on this build - // nothing analyses it and its tests would silently not exist. That is - // how a picker capped at 512 faces on a machine with a thousand of them - // went unnoticed. - const fonts_test = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/config.zig"), + }); + config_module.addImport("pardes_config", core_module.import_table.get("pardes_config").?); + const config_tests = b.addTest(.{ .root_module = config_module, .filters = test_filters }); + const run_config_tests = b.addRunArtifact(config_tests); + run_config_tests.has_side_effects = true; + const config_test_step = b.step("config-test", "run configuration tests without compiling the editor"); + config_test_step.dependOn(&run_config_tests.step); + if (testMatch(b, unit_profile, test_filters, config_test_step)) |check| check.addArtifactArg(config_tests); + const diff_tests = b.addTest(.{ .root_module = hxdiff.root_module, .filters = test_filters }); + const run_diff_tests = b.addRunArtifact(diff_tests); + run_diff_tests.has_side_effects = true; + const diff_test_step = b.step("hxdiff-test", "test differential comparison, allocation failures and CLI regression gates"); + diff_test_step.dependOn(&run_diff_tests.step); + if (testMatch(b, unit_profile, test_filters, diff_test_step)) |check| check.addArtifactArg(diff_tests); + const run_diff_cli = b.addRunArtifact(hxdiff); + run_diff_cli.addArg("--self-test"); + run_diff_cli.has_side_effects = true; + diff_test_step.dependOn(&run_diff_cli.step); + const core_test = b.addTest(.{ .root_module = core_module, .filters = test_filters }); + const run_core_test = b.addRunArtifact(core_test); + run_core_test.has_side_effects = true; + const core_test_step = b.step("core-test", "run core tests without native shell tests"); + core_test_step.dependOn(&run_core_test.step); + if (testMatch(b, unit_profile, test_filters, core_test_step)) |check| check.addArtifactArg(core_test); + const pane_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/fonts.zig"), + .root_source_file = b.path("test/panes.zig"), .link_libc = true, - }) }); - unit_step.dependOn(&b.addRunArtifact(fonts_test).step); - // crt.zig is pure std — the mouse-mapping-vs-shader-formula test - const crt_test = b.addTest(.{ .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/gui/crt.zig"), - }) }); - unit_step.dependOn(&b.addRunArtifact(crt_test).step); - // deck.zig is pure std too; replay.zig drives it with slices of real - // Steam Deck recordings (test/deck/*.zon, deckcap capture format) - const deck_test = b.addTest(.{ .root_module = b.createModule(.{ + }); + pane_module.addImport("pardes", core_module); + pane_module.addImport("pardes_config", core_module.import_table.get("pardes_config").?); + const pane_tests = b.addTest(.{ .root_module = pane_module, .filters = test_filters }); + if (test_nonce) |nonce| pane_tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&pane_tests.step); + const run_pane_tests = b.addRunArtifact(pane_tests); + run_pane_tests.has_side_effects = true; + unit_step.dependOn(&run_pane_tests.step); + if (unit_match) |check| check.addArtifactArg(pane_tests); + const pane_test_step = b.step("pane-test", "run pane, output, PDF and namespace integration tests"); + pane_test_step.dependOn(&run_pane_tests.step); + if (testMatch(b, unit_profile, test_filters, pane_test_step)) |check| check.addArtifactArg(pane_tests); + + const protocol_step = b.step("9p-test", "run freestanding 9P protocol tests"); + const history_step = b.step("history-test", "test historical comparisons and regression gates"); + const protocol_match = testMatch(b, unit_profile, test_filters, protocol_step); + const history_match = testMatch(b, unit_profile, test_filters, history_step); + for ([_]struct { path: []const u8, libc: bool }{ + .{ .path = "test/history.zig", .libc = false }, + .{ .path = "src/9p.zig", .libc = false }, + .{ .path = "src/esp32p4_gpio.zig", .libc = false }, + .{ .path = "src/esp32p4/input_rescue.zig", .libc = false }, + .{ .path = "src/fonts.zig", .libc = true }, + .{ .path = "src/gui/crt.zig", .libc = false }, + }) |entry| { + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(entry.path), + .link_libc = entry.libc, + }), + .filters = test_filters, + }); + if (test_nonce) |nonce| tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&tests.step); + const run_tests = b.addRunArtifact(tests); + run_tests.has_side_effects = true; + unit_step.dependOn(&run_tests.step); + if (unit_match) |check| check.addArtifactArg(tests); + if (std.mem.eql(u8, entry.path, "src/9p.zig")) { + protocol_step.dependOn(&run_tests.step); + if (protocol_match) |check| check.addArtifactArg(tests); + } + if (std.mem.eql(u8, entry.path, "test/history.zig")) { + history_step.dependOn(&run_tests.step); + if (history_match) |check| check.addArtifactArg(tests); + } + } + + const deck_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/deck/replay.zig"), - }) }); - deck_test.root_module.addImport("deck", b.createModule(.{ + }); + deck_module.addImport("deck", b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/gui/deck.zig"), })); - unit_step.dependOn(&b.addRunArtifact(deck_test).step); - // Shell-specific inline tests. main() imports a shell inside its - // runtime switch, which test analysis never enters. main.zig's test - // block names the selected shell, user config, and platform-only - // helpers; its ordinary core import reaches pardes.zig and modal.zig. - // - // `-Dtest-filter=` narrows it. The whole binary is 14s and - // two tests are 8.6s of that, so a one-line change to a module with a - // three-millisecond test used to cost the full run: `zig build - // unit-test -Dtest-filter="an untouched tagline"` is ~1s. The test - // runner's own `--test-filter` never worked here - nothing forwarded - // `--` args to the run step, so it was parsed as a script name. + const deck_test = b.addTest(.{ .root_module = deck_module, .filters = test_filters }); + if (test_nonce) |nonce| deck_test.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&deck_test.step); + const run_deck_test = b.addRunArtifact(deck_test); + run_deck_test.has_side_effects = true; + unit_step.dependOn(&run_deck_test.step); + if (unit_match) |check| check.addArtifactArg(deck_test); + if (platform == .tty or platform == .gui) { const shell_test = b.addTest(.{ .root_module = root_mod, .filters = test_filters }); - unit_step.dependOn(&b.addRunArtifact(shell_test).step); + if (test_nonce) |nonce| shell_test.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&shell_test.step); + const run_shell_test = b.addRunArtifact(shell_test); + run_shell_test.has_side_effects = true; + unit_step.dependOn(&run_shell_test.step); + if (unit_match) |check| check.addArtifactArg(shell_test); + const run_profile = b.addRunArtifact(unit_profile); + run_profile.addArtifactArg(shell_test); + if (b.args) |args| run_profile.addArgs(args); + run_profile.has_side_effects = true; + b.step("unit-profile", "report test request time and process memory as JSONL (-- optional filter, --timeout-ms=N)").dependOn(&run_profile.step); + const profile_test = b.addExecutable(.{ + .name = "pardes-unit-profile-test", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/unit_profile_test.zig"), + }), + }); + const run_profile_test = b.addRunArtifact(profile_test); + run_profile_test.addArtifactArg(unit_profile); + run_profile_test.has_side_effects = true; + b.step("unit-profile-test", "test profiler timing, failures and deadlines").dependOn(&run_profile_test.step); + if (test_filters.len == 0) { + unit_step.dependOn(&run_profile_test.step); + test_build.dependOn(&unit_profile.step); + test_build.dependOn(&profile_test.step); + } } } } -/// Is this build free to choose where it installs? Only when nothing else has -/// said: no DESTDIR, no `--prefix`, and no `--prefix-lib-dir`/`--prefix-exe-dir` -/// /`--prefix-include-dir`. -/// -/// Answered by INSPECTING WHAT THE RUNNER RESOLVED, because the runner records -/// nothing else: `lib/build_runner.zig` keeps the requested step names in a -/// local, and calls `resolveInstallPrefix(install_prefix, dir_list)` (its -/// line 459) before `runBuild`, which substitutes the `zig-out` default for a -/// null prefix and folds the three directory overrides into `lib_dir`/ -/// `exe_dir`/`h_dir`. So by the time `build()` runs, "was it given" survives -/// only as "does it still look exactly like the default". -/// -/// KNOWN LIMIT, stated because it cannot be closed from here: `--prefix` given -/// as the default path spelled absolutely (`--prefix "$PWD/zig-out"`) is -/// indistinguishable from no prefix at all. `announceInstallPrefix` below is -/// the mitigation — the redirect says out loud where it put things, so the case -/// is visible rather than silent. +fn testMatch(b: *std.Build, profiler: *std.Build.Step.Compile, filters: []const []const u8, step: *std.Build.Step) ?*std.Build.Step.Run { + if (filters.len == 0) return null; + const check = b.addRunArtifact(profiler); + check.addArgs(&.{ "--check-filter", filters[0] }); + check.has_side_effects = true; + step.dependOn(&check.step); + return check; +} + fn prefixIsUntouched(b: *std.Build) bool { if (b.dest_dir != null) return false; const zig_out = b.build_root.join(b.allocator, &.{"zig-out"}) catch @panic("OOM"); if (!std.mem.eql(u8, b.install_prefix, zig_out)) return false; - // The three directory overrides do NOT touch `install_prefix`, so without - // this they would pass the test above and then be discarded by the - // `resolveInstallPrefix` call that follows it. return std.mem.eql(u8, b.exe_dir, b.pathJoin(&.{ b.install_path, "bin" })) and std.mem.eql(u8, b.lib_dir, b.pathJoin(&.{ b.install_path, "lib" })) and std.mem.eql(u8, b.h_dir, b.pathJoin(&.{ b.install_path, "include" })); } -/// The commit this build came from, or null when there is nothing to say. -/// -/// Read at CONFIGURE time and handed to `pardes_config`, so the binary carries -/// a string rather than the ability to shell out. That is the whole point: a -/// `--version` that runs `git` itself reports the tree it happens to be -/// standing in rather than the one it was built from, and on the board there is -/// no `git` to run and no process to run it with. -/// -/// ABSENCE IS NOT AN ERROR, and must not be. A release tarball has no `.git`, a -/// container may have no `git` binary, and a source drop is not a repository — -/// none of those is a reason to refuse to build. Every way of having no answer -/// (no binary, no repository, a git that exits non-zero, a git that prints -/// nothing) lands on the same `null`, and the frontends print the version alone. -/// -/// Deliberately NOT `--dirty`. Marking a dirty tree costs a worktree stat on -/// every configure, and — the real cost — it would change `pardes_config` on -/// every file edit. Every module in this build imports that options module, so -/// a dirty marker means editing one line rebuilds the world. The commit alone -/// changes only when a commit does. fn gitCommit(b: *std.Build) ?[]const u8 { var code: u8 = 0; - // `-C` the build root rather than trusting the cwd: `zig build` may be run - // from anywhere, and a `git` resolved against the wrong directory would - // cheerfully answer about a DIFFERENT repository. const out = b.runAllowFail( &.{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--short=12", "HEAD" }, &code, @@ -1826,11 +1313,6 @@ fn gitCommit(b: *std.Build) ?[]const u8 { return if (trimmed.len == 0) null else trimmed; } -/// Everything `pardes_config` says that does NOT depend on which frontend is -/// being built. Gathered into one value so the two options modules a default -/// build makes cannot drift apart in any field but the one that is supposed to -/// differ. Adding a field here is additive for both shells at once, which is -/// the property that makes two shells in one build cheap to keep honest. const ShellConfig = struct { tree_sitter_grammars: TreeSitterGrammars, tracy: bool, @@ -1843,15 +1325,9 @@ const ShellConfig = struct { theme_animation: ?bool, prebuilt_shaders: bool, zig_lib_dir: []const u8, - /// Which commit this build came from, or null when there is no answer. Read - /// once in `build()` so the two shells of a default build cannot disagree, - /// and so `git` is spawned once rather than per frontend. commit: ?[]const u8, }; -/// One `pardes_config` options module, for one frontend. `module_names` and -/// `module_roots` are NOT here: they are folded out of the module import table -/// long after this runs, and are added to every returned module then. fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build.Step.Options { const o = b.addOptions(); o.addOption(Platform, "platform", platform); @@ -1868,24 +1344,11 @@ fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build. // Meaningful only for the SDL shell. Keeping the platform condition here // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt". o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders); - // Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it - // names: the .zon pins a commit, and a status screen that cannot say WHICH - // analyser answered is not worth opening. o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none"); - // THE version, read from the manifest rather than copied beside it. The - // `@import` being UNTYPED is what makes that possible: annotating its type - // would demand an exact field match and reject `.dependencies`, `.paths` - // and the rest, which is the failure the hand-synced literal that used to - // sit here was working around. Verified against this exact manifest. o.addOption([]const u8, "version", zon.version); // ...and the commit it was built from, when there is one. See `gitCommit` // for why this is optional and why it is read at configure time. o.addOption(?[]const u8, "commit", cfg.commit); - // The stdlib this binary was compiled against, so `gd` on `std.mem.count` - // can open the same mem.zig the compiler used. ZLS resolves `@import("std")` - // through `zig_lib_dir` and nothing else; without it every std symbol is a - // silent miss, and asking the `zig` binary for it is the subprocess this - // whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime. o.addOption([]const u8, "zig_lib_dir", cfg.zig_lib_dir); return o; } @@ -1897,10 +1360,6 @@ fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { b.getInstallStep().dependOn(fail); } -/// Fold `lib` and every static archive it transitively links into one file, -/// because swiftc is handed exactly one. getCompileDependencies walks the -/// module graph, so this stays correct as dependencies come and go — nothing -/// here names MuPDF or tree-sitter, and adding a third C library needs no edit. fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, "libtool libpardes.a"); run.addArgs(&.{ "libtool", "-static", "-o" }); @@ -1912,14 +1371,6 @@ fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { return output; } -/// Rewrite one archive's index with Apple's ranlib, on the way past. Two of -/// Xcode's tools disagree with zig's archive layout and neither says so -/// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o' -/// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input -/// came back as a 13 MB output with half the objects missing, which links -/// almost far enough to look like a source problem. ranlib rewrites both -/// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the -/// copy is because ranlib works in place and a build-cache input is not ours. fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index})); run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" }); @@ -1937,11 +1388,6 @@ fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name})); } -/// The theme sources, sorted. SORTED because the run step is cached by its -/// argv: readdir order is whatever the filesystem feels like, and an argv that -/// shuffles is a cache miss and a rebuild every time. Anything that is not a -/// .toml or a .json is skipped, which is what lets the upstream LICENSE files -/// sit beside the themes they cover. fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { var count: usize = 0; { @@ -1973,13 +1419,6 @@ fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { return names; } -/// Everything every compilation of src/pardes.zig needs, in one call per module -/// rather than a line per module beside every dependency — which is the shape -/// that let a third module quietly miss two of them. -/// -/// Fields are in the order these used to be attached in, because that order IS -/// the module map `Debug` prints (import_table iterates by insertion), and a -/// refactor that renumbers a user-visible list changed something. fn wireCore(m: *std.Build.Module, d: struct { themes: ?*std.Build.Module = null, zls: ?*std.Build.Module = null, diff --git a/build/snap.zig b/build/snap.zig index 766be2b4..2b549e63 100644 --- a/build/snap.zig +++ b/build/snap.zig @@ -1,15 +1,5 @@ -//! Build-time wiring for the two snapshot suites — the `snap` step (scripted -//! pty traces diffed against test/snapshots/*.golden) and the `web-snap` step -//! (the same idea driven through headless Chromium). Kept out of build.zig so -//! the test harness's plumbing is not tangled with the shell/platform build. -//! -//! Both steps forward `-- ` to their runner: `--update` regenerates -//! goldens, `--jobs=N` / `--idle=MS` tune the tty runner's parallelism. - const std = @import("std"); -/// TTY parity suite: pardes-snap forks the real binary in a pty per script. -/// The runner handles its own locking and fan-out, so this is just a run step. pub fn addTty(b: *std.Build, opts: struct { exe: *std.Build.Step.Compile, target: std.Build.ResolvedTarget, @@ -26,15 +16,20 @@ pub fn addTty(b: *std.Build, opts: struct { }), }); if (opts.ghostty_vt) |vt| snap.root_module.addImport("ghostty-vt", vt); + const ninep_options = b.addOptions(); + ninep_options.addOption(bool, "quic", false); + const ninep_client = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = b.path("src/9p_io.zig"), + .link_libc = true, + }); + ninep_client.addOptions("9p_options", ninep_options); + snap.root_module.addImport("9p_io", ninep_client); switch (opts.target.result.os.tag) { .freebsd, .netbsd, .openbsd => snap.root_module.linkSystemLibrary("util", .{}), else => {}, } - // The deterministic mock language server (test/lspmock.zig): the runner - // points PARDES_LSP_RS at it, so the lsp-client scripts drive the REAL - // protocol client — spawn, handshake, reader thread, progress narration — - // against answers a golden can quote byte for byte. No pardes import; it - // is a foreign binary on purpose. const lspmock = b.addExecutable(.{ .name = "pardes-lspmock", .root_module = b.createModule(.{ @@ -44,12 +39,6 @@ pub fn addTty(b: *std.Build, opts: struct { .link_libc = true, }), }); - const install_lspmock = &b.addInstallArtifact(lspmock, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; - // Installed by `snap` and not by the default install step, and into - // `/dev` rather than `/bin`: a bare `zig build` installs - // the two shells into the user's own bin directory, and this harness is - // neither of them. - const install_snap = &b.addInstallArtifact(snap, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_snap = b.addRunArtifact(snap); run_snap.addArtifactArg(opts.exe); run_snap.addPrefixedFileArg("--lspmock=", lspmock.getEmittedBin()); @@ -57,16 +46,18 @@ pub fn addTty(b: *std.Build, opts: struct { // scripts fork children and drive real ptys: never cached, never "up to date" run_snap.has_side_effects = true; const snap_step = b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)"); - snap_step.dependOn(install_snap); - snap_step.dependOn(install_lspmock); snap_step.dependOn(&run_snap.step); + const run_driver_test = b.addRunArtifact(snap); + run_driver_test.addArg("--self-test"); + run_driver_test.has_side_effects = true; + b.step("snap-driver-test", "test snapshot retry evidence and failure reporting").dependOn(&run_driver_test.step); } -/// Browser regression suite: make an isolated freestanding web build from the -/// real TTY dump captured from Git's tracked-plus-new/nonignored `.zig` view. -/// The dependency-free Chrome/CDP driver then exercises real DOM pointer events. -/// This recursive build selects `web`, not `web-snap`, so it bottoms out. pub fn addWeb(b: *std.Build) void { + const driver_test = b.addSystemCommand(&.{ "node", "--test", "test/web_driver_test.mjs" }); + driver_test.setCwd(b.path(".")); + driver_test.has_side_effects = true; + b.step("web-driver-test", "test browser driver deadlines and cleanup").dependOn(&driver_test.step); const build_web_snap = b.addSystemCommand(&.{ b.graph.zig_exe, "build", @@ -74,7 +65,7 @@ pub fn addWeb(b: *std.Build) void { "-Dplatform=web", "-Dtarget=wasm32-freestanding", "-Dtree-sitter=zig", - "-Ddump=test/web-snapshots/source-list.dump.zon", + "-Ddump=test/web-snapshots/touch.dump.zon", "-p", "zig-out/web-snap-test", }); @@ -83,11 +74,13 @@ pub fn addWeb(b: *std.Build) void { "node", "test/web_snapshot.mjs", "zig-out/web-snap-test/web", - "test/web-snapshots/source-list.snap", + "test/web-snapshots/touch.mjs", }); run_web_snap.setCwd(b.path(".")); run_web_snap.step.dependOn(&build_web_snap.step); + run_web_snap.step.dependOn(&driver_test.step); + run_web_snap.has_side_effects = true; if (b.args) |args| run_web_snap.addArgs(args); - b.step("web-snap", "run browser touch/LOOK snapshots (-- --update regenerates goldens)").dependOn(&run_web_snap.step); + b.step("web-snap", "run browser highlighting, touch, Look, resize and scroll checks").dependOn(&run_web_snap.step); b.step("web-e2e", "run the Chrome-driven DOM end-to-end suite").dependOn(&run_web_snap.step); } diff --git a/docs/9p.typ b/docs/9p.typ index 82131b9d..50cec965 100644 --- a/docs/9p.typ +++ b/docs/9p.typ @@ -44,6 +44,11 @@ #v(1.2em) +*Historical proposal, superseded by `docs/fs.md`.* Pardes now serves 9P by +default over Unix sockets, with optional TCP and QUIC. FUSE support and the +old examples have been removed. The arguments and source references below +describe the earlier implementation, not the current interface. + #block(inset: (x: 2.5em))[ #set text(size: 10pt) #set par(first-line-indent: 0em) diff --git a/docs/acme-fs.md b/docs/acme-fs.md deleted file mode 100644 index 25cbcc2d..00000000 --- a/docs/acme-fs.md +++ /dev/null @@ -1,431 +0,0 @@ -# The control filesystem — pardes against acme - -`pardes --fs` serves plan9 [acme(4)](https://man.cat-v.org/plan_9/4/acme)'s control -filesystem over Linux FUSE: a directory per pane, named by the pane's serial and -holding `addr`, `body`, `ctl`, `data`, `errors`, `event`, `rdsel`, `tag`, `wrsel` -and `xdata`, plus a `pty/` directory on a terminal pane, plus `index`, `cons` -and `new/` at the root (`PaneFile` and -`TopFile` in `src/acmefs.zig`). A program that opens those files IS an editor -extension — no plugin API, no embedded interpreter, no rebuild. -`examples/acmefs/` has four of them: `clock.py`, `eventlog`, `life.py` and -`pardesctl`. - -This document is the **comparison report**: what acme does, what pardes does, -why they differ, and which one is simpler. acme's C is at -`/home/goblin/05-genizah/principia-softwarica/editors/acme` — the -principia-softwarica tree and not plan9port, which matters because the two -differ in what they serve; every `file:line` below is that checkout's. Every -claim cites both sides. Where acme is better, it says so. - -| | acme | pardes | -|---|---|---| -| transport | 9P over a pipe, own implementation | raw `/dev/fuse`, own codec (`src/fuse.zig`) | -| concurrency | 1 server thread + **one thread per in-flight request** | none in the core; one `poll()` thread in the transport | -| blocking read | park an `Xfid` in `w->eventx`, wake from `winevent` | `Status.again`, re-asked by the transport | -| offsets | runes | bytes, grapheme-clamped | -| node ids | `QID/WIN/FILE` shift macros | `packed struct(u64) { file: u4, serial: u60 }` | -| errors | 9P error strings (`Ebadctl`, `Edel`, ...) | errno | -| event queue | `realloc` per record, unbounded | length-framed `ArrayList`, capped, drop-oldest | -| `ctl` write | applies the good prefix, then reports 0 consumed | validate-all then apply-all | - -## 1. The service: threads-and-channels against one transaction - -acme runs a dedicated process for the wire (`proccreate(fsysproc)`, `fsys.c:136`) -whose loop reads a 9P message, borrows an `Xfid` from a pool, and dispatches -through the `fcall[x->type]` function table (`fsysproc`, `fsys.c:140-193`; the -table itself at `fsys.c:41`, the dispatch at `fsys.c:190`). Directory reads and -stats are answered inline; anything that touches a window is handed to that -`Xfid`'s own thread — -`sendp(x->c, xfidread)` (`fsys.c:660`) — and `xfidallocthread` creates **one -thread per `Xfid`** on first use (`acme.c:718-744`), each parked in -`for(;;){ f = recvp(x->c); (*f)(x); ... }` (`xfidctl`, `xfid.c:42-55`). -Serialisation is by `QLock`: one on the row (`dat.h:313`), one per window plus an -owner byte (`dat.h:228` and `dat.h:250`, taken together in `winlock1`, -`wind.c:131-136`), one on the mount table (`struct Mnt`, `fsys.c:96`, taken at -`fsys.c:201`). - -pardes has none of that. A request is a value, an answer is a value, and the -whole filesystem is one function: - -```zig -pub fn handle(p: *Pardes, req: Req) Reply // src/acmefs.zig -``` - -It arrives as an ordinary `Event.fs_req` and leaves as an ordinary -`Effect.fs_reply` (`src/pardes.zig`), so the transport is the queue every other -host↔core message already uses, and the core keeps the single-threaded model it -had. All the concurrency lives in `src/fuse.zig`: one thread that `poll()`s the -fd and wakes the loop, and a park table for requests the core answered with -"not yet". The thread never touches core state, never parses a request, and -never writes a reply — the same discipline `src/file_watch.zig`'s inotify thread -already followed. - -**Simpler: pardes, by a lot.** No channels, no locks, no thread per request, no -fid bookkeeping, and the semantics are unit-testable with no scheduler and no -FUSE anywhere near them (`src/acmefs.zig` has 21 such tests). - -**What acme buys, honestly:** isolation. Its request threads mean a slow read -cannot stall the editor. In pardes `handle` runs on the loop thread, so a -pathological request — reading the body of a 100 MB file, a `ctl get` that -re-reads a huge file from disk — is a frame the user waits for. The measured -numbers say this is theoretical rather than practical, but it is a real property -of the design and the reason acme's complexity exists. - -Every measurement in this document is one run of `zig build fs-bench --Doptimize=ReleaseFast` on an i7-11700. Each row is `handle` called directly — -no FUSE, no thread — and its figure is the MEAN over the row's reps: 100 000, -except 10 000 for the 1 MiB read, 200 for the append and 2000 per keystroke row. - -| row | per request | allocations, whole row | -|---|---|---| -| `getattr` on a 1 MiB body | 19 ns | 0 | -| `lookup ctl` | 39 ns | 0 | -| `read body`, 4 KiB | 25 ns | 0 | -| `read body`, 1 MiB | 25 ns | 0 | -| `read ctl` | 404 ns | 0 | -| `read index` | 631 ns | 0 | -| `readdir` of the root | 39 ns | 0 | -| `read event` on an empty queue (`Status.again`) | 21 ns | 0 | -| `write body`, 1 KiB appended to a body growing from 1 MiB | 3.36 ms | 600 | - -Two things in that table are the point of having it. The rows that FORMAT — -`ctl` and `index`, which `bufPrint` a line of `%11d` fields — cost about twenty -times a row that hands back a slice, and are still well under a microsecond. And -a 1 MiB `body` read costs exactly what a 4 KiB one does, because it is -zero-copy: `Payload.region` is a window onto the pane's live text. Every read -row allocates nothing at all, which is a property the benchmark exists to check -rather than a pleasing number — a non-zero count there would mean a read had -stopped answering out of the live text or out of the staging buffer that is -cleared and never freed. The one row that allocates is the append, at 600 -allocations across 200 writes, and that is the core's whole-body swap plus its -undo snapshot rather than anything this filesystem does. - -## 2. Blocking reads: a parked thread against a returned value - -acme's `event` read blocks: `xfideventread` (`xfid.c:994-1025`) stores its `Xfid` -in `w->eventx`, unlocks the window and sleeps on a channel (`xfid.c:1008-1010`); -`winevent` (`wind.c:543-569`) appends the record and wakes it; `windelete` -(`wind.c:217-225`) wakes it with no data so it can answer "window shut down" -(`xfid.c:1005`); and `xfidflush` (`xfid.c:58-87`) exists solely to cancel a -parked reader — it walks every column and every window looking for the tag, -because a blocked 9P read cannot otherwise be interrupted. - -pardes returns `Status.again` — "nothing consumed, ask me again" — and that is -the entire blocking primitive. The core keeps no waiter, no channel, no cancel -path. The transport parks the kernel's request (`max_slots = 32`, `src/fuse.zig`) -and re-offers it once per frame, oldest first, with a per-round flag so a -permanently blocked reader cannot starve the others (`retry`). A `FUSE_INTERRUPT` -answers the original with `-EINTR`, which is what keeps a SIGKILLed reader out of -permanent uninterruptible sleep: after a fatal signal `fuse_dev`'s final -`wait_event` is not killable, so the process survives its own kill until the -server replies. Three tests pin that mechanism — "again holds the request, retry -offers it back once per round", "interrupt answers the original with EINTR and -drops it", and "a full table answers EAGAIN and keeps the descriptor flowing", -whose comment records that the tempting alternative, gating the read on a free -slot, wedges a real mount: the INTERRUPT that would free a slot is never read -either. - -Two deliberate differences: - -- acme hands back up to `count` bytes and keeps the remainder, so a small read - can split a record (`xfid.c:1015-1017`). pardes refuses a read smaller than one - record with `EINVAL`: half a record is unparseable and silently desynchronises - a client. -- acme's parked thread survives the client's death (it stays blocked until the - window produces an event). pardes has nothing to leak — the kernel drops the - request. - -**Simpler: pardes.** **acme buys** an unbounded number of blocked readers; ours -are bounded by the park table because each one is a held kernel request. - -## 3. Node identity - -acme packs a qid with macros: `QID(w,q) ((w<<8)|(q))`, `WIN(q)`, `FILE(q)` -(`dat.h:463-465`) — 24 bits of window id, 8 of file id, no validation. A window -that dies while a client holds a file open is detected structurally, by every -handler remembering to check `if(w->col == nil) respond(..., Edel)` -(`xfid.c:422-425` in `xfidwrite`, and a dozen more; the string is at -`xfid.c:19`). - -pardes uses the type system: - -```zig -pub const Node = packed struct(u64) { file: u4 = 0, serial: u60 = 0 }; -``` - -`Node.target(node)` is the ONE place that validates, returning a tagged union of -"top-level file" or "pane file", so no handler re-decodes and none can forget. -`serial` is the pane's monotonic identity, never reused, so a stale path can go -dead but can never come to mean a different pane — and `State.forget`, called -from `deinitPane`, drops the pane's filesystem state at the moment it dies, -which is also what stops a dead script's reader count from suppressing button -actions forever. - -**Simpler: pardes.** The packed struct is the same bits with the shifts checked, -the sentinel-terminated `Dirtab` tables become enums with `name()`/`mode()` -methods, and `Edel`-by-convention becomes `ENOENT` by construction. - -## 4. Addressing: runes against bytes - -acme's document is `Rune*`, so every read converts. `xfidutfread` -(`xfid.c:875`) keeps a byte-to-rune cache per window — `w->utflastqid`, -`utflastboff`, `utflastq` (`xfid.c:891-897`) — and, when it misses, scans from -the beginning, carrying the comment `/* BUG: stupid code: scan from beginning */` -(`xfid.c:895`). The address language lives in `addr.c`: `number()` -(`addr.c:52`), `regexp()` (`addr.c:119`), `address()` (`addr.c:150`), with -failure reported through out-parameters (`int *evalp`, `uint *qp`) and patterns -grown one rune at a time. - -pardes is byte-addressed end to end (selections, look spots, LSP offsets), so -**every offset in this filesystem is a byte offset**, clamped to grapheme -boundaries — the one deliberate incompatibility with acme(4), stated in -`src/acmefs.zig`'s header and in `examples/README.md`. For ASCII, which is what -scripts compute with, the two agree. The address parser is the same left-to-right -state machine as `addr.c` with the C removed: the expression is a slice, the -cursor is a field, "did not evaluate" is `?Range`, and `limit=addr` is an -optional rather than a sentinel `-1`. - -**Simpler: pardes** — the entire rune↔byte layer and its cache do not exist. -**acme buys** rune semantics at every boundary, which is what its own manual -promises; ours promises bytes. - -## 5. Events, and the inversion that makes this a plugin API - -The record is the same on both sides, byte for byte: origin char, type char, four -blank-separated decimals, the text, a newline. acme builds it in two halves — -`text.c:380` formats `"%c%d %d 0 %d %.*S\n"` and `winevent` prepends the owner -byte at `wind.c:561` — and pardes builds it in one, `formatRecord` -(`src/acmefs.zig`). - -The rule that matters is the inversion: **while a script holds a pane's `event` -file open, buttons 2 and 3 in that pane belong to the script.** acme spells it -`if(!external && t->w!=nil && t->w->nopen[QWevent]>0){ winevent(...); return; }` -(`exec.c:150`, `look.c:35`); pardes spells it as the return value of -`noteAction` — "true means the core must not perform it" — checked in -`dispatchPointerBuiltin`. That is how `examples/acmefs/life.py` puts -`Step Run Stop Clear Random` in a tag pardes has never heard of and makes them -work. `test/snapshots/acmefs-event.snap` drives the A/B proof and -`acmefs-event.golden` records it: with a reader attached, a middle click on the -word `Newcol` changes nothing on screen and delivers `MX0 6 1 6 Newcol` -(`acmefs-event.golden:9`); with the reader gone, the same click opens a column. - -Differences worth knowing: - -- **Write-back takes four fields only** — `origin type q0 q1\n`, type in `xXlL` - — exactly as `xfideventwrite` demands (`xfid.c:791-872`: it reads the origin - byte, the type char, two `strtoul`s and a mandatory newline, and its `switch` - takes `x`, `X`, `l`, `L` and sends everything else to `Rescue`, which is - `err = Ebadevent`). There is no text field, so a client that wants to run text - not already on screen appends it to the tag and execs that range; - `examples/acmefs/pardesctl exec` does precisely this — `printf ' %s' "$text" - >>$d/tag` and then `printf 'Mx%d %d\n' "$q0" "$q1" >>$d/event` — and it is how - acme clients have always done it. -- pardes validates a whole batch of records before performing any of them; acme - performs them as it parses, which `writeEvent`'s own comment in - `src/acmefs.zig` names as the reason: a malformed batch is otherwise - half-applied and unrepeatable. -- acme records the origin byte the RECORD claimed — `w->owner = *p++;` with - `/* disgusting */` beside it (`xfid.c:812`) — and stamps it onto every record - it later produces (`wind.c:561`). pardes sets `State.origin` once per update - from the event kind (`K` keyboard, `M` mouse, `E` a write to body or tag - through this filesystem, `F` an action through one of its other files), - parses the character on the way in and drops it. **acme is arguably better - here**: its owner byte is per-record provenance and a writer can re-attribute - an action. Against that, a record saying where it came from is worth nothing - when the sender picks the answer, which is why pardes does not read it. - -## 6. Reporting edits: known ranges against a diff - -acme reports from the two functions that make edits, which already know their -range: `textinsert` emits `I` with `q0, q0+n` and the inserted runes -(`text.c:377-382`), `textdelete` emits `D` with `q0, q1` (`text.c:482`). - -pardes has no such pair — every edit lands in one place as a whole new buffer -(`file_pane.setContent`) — so the range is recovered by diffing there: -`acmefs.diffSpan` skips the common prefix and suffix in 64-byte chunks through -`std.mem.eql`, which lowers to vectorised compares, and `noteReplace` emits the -deletion then the insertion, the same two records in the same order. The -vectorising is not premature: its own comment records that the byte-at-a-time -loop it replaced cost 2.4x per keystroke on a 40 KB body. The whole path is -behind `p.fs.scripted(id)` — that pane's reader count, not the session-wide -`listeners` total — so an editor nobody is scripting pays one branch. Measured -cost of a keystroke on a 32 KiB body: 32.9 µs with no listener against 34.1 µs -with one, **+3.9%**. - -**acme is better here in principle** — a known range beats a scan — and it pays -for it by routing every mutation through a pair of functions that carry ranges -everywhere. pardes's single funnel is worth more than the scan costs. - -Undo grouping is acme's `mark`/`nomark` on both sides: acme bumps a global -sequence number and merges an `elog` of edits (`elog.c`; `if(w->nomark == FALSE) -{ seq++; filemark(t->file); }` at `xfid.c:501-504`); pardes suppresses the -per-write `pushUndo` snapshot. Same verb, same effect on the user's `u`, much -less machinery — and the reason it matters is measurable: the benchmark's append -row is 3.36 ms per 1 KiB write against a body around a megabyte, almost all of -it the whole-body swap and that snapshot, so a script writing a batch should say -`nomark` first. - -## 7. `ctl` - -Both print the same five `%11d` fields — id, tag length, body length, isdir, -dirty (`winctlprint`, `wind.c:534-535`) — and pardes adds acme's three extras -(`wind.c:537-538`) with the one honest substitution: width and tab in **cells**, -because pardes is a character grid where acme has pixels (`Dx(w->body.r)`). - -The verb parsers differ in two ways that matter: - -- acme matches verbs by **prefix** with `strncmp` and advances by the matched - length (`xfid.c:602-767`), so the table order is load-bearing: `delete` - (`xfid.c:697`) must precede `del` (`xfid.c:701`), `nomark` (`xfid.c:738`) - `mark` (`xfid.c:742`), `nomenu` `menu`, `noscroll` `scroll`. pardes matches a - whole token through `std.meta.stringToEnum`, which makes that class of bug - unrepresentable. -- acme applies verbs as it parses, so a bad verb leaves the good prefix applied - — the mutations already made stand — and then reports **zero** bytes consumed: - `err = Ebadctl` (`xfid.c:769`) falls through to `if(err) n = 0; fc.count = n;` - (`xfid.c:780-782`), and `xfideventwrite` repeats it verbatim at - `xfid.c:863-865`. So the client learns that it failed but not where, and the - editor has already been half-changed. pardes validates every verb first and - then applies them: a short count on a Linux `write(2)` is not read by anybody - as "the rest failed", and a half-applied batch is unrepeatable. **This is not - a trade**; the atomic answer is simply the better one. - -Verbs pardes cannot honour are refused loudly with a reason — -`refused_verbs` is `dump`, `dumpdir`, `font`, `lock`, `menu`, `nomenu`, -`unlock` — rather than silently accepted. - -## 8. Errors - -acme answers with strings: `Edel` "deleted window", `Ebadctl` "ill-formed control -message", `Ebadaddr` "bad address syntax", `Eaddr` "address out of range", -`Ebadevent` "bad event syntax" (`xfid.c:19-24`), handed through -`respond(x, &fc, err)`. pardes answers with an errno, because that is the only -channel FUSE has: the client would never see the string. Two acme errors that -differ in wording collapse to `EINVAL` here, which is a real loss of -diagnostics — the message row and `PARDES_LOG` carry the detail instead. - -## 9. Memory and bounds - -acme grows `w->events` with `realloc` and never caps it (`wind.c:560`), and -re-allocates the remainder on every partial read — `w->events = -estrdup(w->events+n); free(b);` (`xfid.c:1022-1023`). A client that stops reading -grows that buffer until `emalloc` fails and acme aborts. - -pardes's queue is length-framed (records contain newlines, so a length is the -only way to hand one back whole), capped at 64 KiB per pane (`queue_cap`), and -drops the oldest record when full: an editor must not stall or grow without bound -because a script stopped reading, and a reader that far behind can re-read `body` -and resynchronise. Formatted answers go into one staging buffer that is cleared -and never freed, which is why every read in the benchmark reports zero -allocations. **acme's unbounded buffer is a flaw, not a feature.** - -## 10. C-isms Zig removed - -Ranked by what they cost when they go wrong: - -1. **Threads and channels standing in for a state machine** — one thread per - in-flight request (`acme.c:718-744`, `xfid.c:42-55`) → a `Status.again` return - value and a park table in the transport. -2. **Macro-packed qids** — `QID/WIN/FILE` (`dat.h:463-465`) → `packed - struct(u64)` with one validating constructor. -3. **`Rune*` plus a byte-to-rune cache** with a scan-from-zero fallback - (`xfid.c:891-897`) → byte slices clamped to grapheme boundaries. -4. **`strtoul` pointer walking with `goto Rescue`** (`xfid.c:810-838`) → a slice - reader returning `?u32`. -5. **`longjmp`-ish `error()`** that aborts the process (`util.c:50-55`) → an - error union and a `Reply` value. -6. **Manual `realloc` growth** (`wind.c:560`) → `ArrayList` with retained - capacity. -7. **Sentinel-terminated tables** (`dirtab`, `fsys.c:62-74`; `dirtabw`, - `fsys.c:76-91`) → exhaustive enums, so adding a file to the tree does not - compile until every switch has an answer for it. -8. **`sprint` into fixed buffers** (`wind.c:534`) → `bufPrint` returning an - error. -9. **Ownership by convention** — `fbufalloc`/`fbuffree` pairs the caller must - match (`fns.h:5-6`) → `defer`, plus two explicit borrow windows - (`Payload.staged`, `Payload.region`) documented at the seam. -10. **Prefix-matched command tables** whose order is load-bearing - (`xfid.c:602-767`) → whole-token enum lookup. - -One property comes along with the transport rather than with either design: a -9P `Twrite` IS a message, so acme never sees a fragment, while a POSIX client -can call `write(2)` with one byte. A `ctl` verb, an `addr` expression and an -`event` record must therefore each arrive in a single write here, and a -fragment is EINVAL rather than state kept in the editor waiting for the rest. -Every ordinary client already does this (stdio buffers; `echo`, `dd` and -`os.write` are one call each), and the alternative — a per-pane line buffer — -would trade a clear error for a half-applied verb that never completes. - -## What is not served, and why - -`acme`, `consctl`, `draw`, `editout`, `label` — acme's root `dirtab` -(`fsys.c:62-74`) keeps them for rio and for its own `Edit` language, neither of -which pardes has, and `editout` appears in the per-window `dirtabw` -(`fsys.c:76-91`) for the same reason. Everything else in `dirtabw` is here: -`addr`, `body`, `ctl`, `data`, `errors`, `event`, `rdsel`, `tag`, `wrsel`, -`xdata`, and `index`, `cons` and `new/` at the root. `log` is NOT — and this is -the one entry that is not a decision about acme, because this acme's `dirtab` -has no `log` either; it is a plan9port addition. No example needed it, and a -script that wants to notice panes it did not open reads `index`, which is what -acme gives it. - -## What is served that acme does not have: `pty/` - -One directory, three files, and **no prior art anywhere**: acme has no terminals -and `ad` — the other editor that serves a control filesystem over 9P — has no -terminal surface at all (its tree is `{ctl, minibuffer, scratch, log, -buffers/…}`). So there is nobody's mistakes to learn from and nobody's scripts -to keep compatible, which is the argument for keeping it to three files and -stopping. - -A pty is a file interface wearing the wrong clothes: everything one wants to do -to it is an `ioctl`, and neither 9P nor FUSE has one. They become writes. - -| ioctl | here | -|---|---| -| `TIOCSWINSZ` | `winsize 80 24` → `pty/ctl` | -| `kill` | `sig INT` → `pty/ctl` | -| spawn | `exec` → `pty/ctl` | -| `TIOCGWINSZ` | read `pty/status` | -| `read`/`write` | `pty/data` | - -Two of the three verbs are effects the core already had — `push_spawn` and -`push_pty_resize` — so `exec` and `winsize` are existing capabilities acquiring -a name. `sig` is the one new host capability in the whole directory -(`push_pty_signal`, and there was no `kill` anywhere in `host_io.zig` before -it); it targets the tty's foreground process group rather than the shell's pid, -because an interactive shell ignores SIGINT while it waits for a job. - -The directory is **absent** on a pane that is not a terminal, rather than -present and refusing, so `test -d /pty` is how a script asks what kind of -pane it has. `pty/data`'s read is the only new state: the core keeps no raw pty -bytes anywhere (they go into the emulator grid, which is a rendering and cannot -be turned back into a stream), so they are queued as they arrive — gated on a -reader count exactly as `event` is, so a pane nobody is reading costs one -branch and no memory, and capped drop-oldest by the same `queue_cap`. Unlike -`event`, a read smaller than one arrival is SERVED and the remainder kept: raw -bytes have no record framing to split down the middle. - -Three things it deliberately does not have. There is no **exit status**, -because the core does not track one: a shell's death arrives as `Event.eof`, -whose whole handler is `removePane`, so by the time anyone could read a status -the directory is gone. There is no **`raw`/`cooked`**, because the termios -belongs to the program on the far side of the pty and it never reports one. And -`exec` takes **no argv**, because `Effect.spawn` carries a pane and a cwd and -has nowhere to put one — so `exec /bin/sh` is EINVAL rather than an argument -accepted and silently ignored. - -`Node.file` is a `u4`. These four variants (`pty`, `pty_ctl`, `pty_status`, -`pty_data`) take it to fifteen of sixteen used: **one value is left**, and the -next file added to a pane's directory needs a wider field and therefore a new -node-id layout. That is also why `pty/` is a directory rather than three more -names beside `body` — a subdirectory costs one value and buys a namespace, so -`ctl` and `data` did not have to be spelled twice. - -## Reading order - -`src/acmefs.zig` (semantics; start at its header), `src/fuse.zig` (the wire), -`src/fs_service.zig` (mount lifecycle), `examples/README.md` (the client's view), -`test/snapshots/acmefs.snap` and `acmefs-event.snap` (the drivers, i.e. what is -exercised end to end) beside their `.golden` files (what was observed), -`zig build fs-bench` (what it costs). diff --git a/docs/config.md b/docs/config.md index 448ac763..f895e599 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,9 +12,9 @@ main command file is named `init`: 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 (`user_config.xdgBase`, and the test beside +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, `src/user_config.zig` — or that cannot be read at all is +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 @@ -29,7 +29,7 @@ and size, tagline scale, 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 four fields of `runtime_config.Capabilities` gate +empty supported setting. The four 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 @@ -47,7 +47,7 @@ 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. -The mutable global values live together in the plain `runtime_config.State` record. +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 @@ -75,12 +75,13 @@ 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` or `tagline_size` in `src/runtime_config.zig`) takes +`shell`, `theme`, `font` or `tagline_size` 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`, -`Find`, `Grep`, `Rename`, `WsSymbols`, `Look`, `Exec`, `Msg` and `EffectCode`. +`Mount`, `Unmount`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`, `Exec`, +`Msg` and `EffectCode`. (`Peek`, `Poke`, `Hexdump` and `Gpio` take one too, but they exist only where -`board_memory.enabled` holds, and that build has no config file.) +`builtins.Board.enabled` holds, and that build has no config file.) `Theme ` wants one of the 228 names in the ring. Do not derive the spelling — read it off `ThemeSel` (`SPC t t`), which lists every one as the @@ -325,18 +326,12 @@ pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom, curvature, and noise rather than remapping the theme to a strong fixed palette; Ripple and Glitch primarily perturb sample coordinates. -`EffectCode ` opens the build-embedded effect math, host -paint/submission path, and backend shader/grid sources, for example -`EffectCode PanelAscii` or, in a GUI build, `EffectCode Crt`. TTY exposes it -for its grid transitions; native GUI builds -expose it for transitions and scene shaders. It is absent on web, where no -effect argument could succeed. Shared -passes are shown as shared source segments rather than manufactured per-effect -copies. The command works from an installed binary and does not need the source -checkout beside it. SDL output also labels its shader provenance. An ordinary -build prints the live GLSL that `glslc` compiled for that executable; -`-Dprebuilt-shaders` prints the tracked GLSL snapshot paired with the committed -SPIR-V instead and labels those segments with their `shaders/prebuilt/` paths. +`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. 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. @@ -361,15 +356,15 @@ pub const look_preview_delay_frames: ?u16 = null; ## Build-time configuration -Everything above is chosen at runtime or in `src/config.zig`. The build itself -takes these, and this is the whole list — every `b.option` in `build.zig`, -besides `-Dtarget` and `-Doptimize` from `standardTargetOptions` and -`standardOptimizeOption`: +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. | option | values | default | |---|---|---| | `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent builds the tty cli and the SDL gui together | | `-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 | | `-Dtree-sitter` | `disabled`, `zig`, `minimal`, `full` | `full` natively, `zig` for web, `disabled` for esp32p4 | @@ -379,17 +374,13 @@ besides `-Dtarget` and `-Doptimize` from `standardTargetOptions` and | `-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` | -| `-Desp32p4-cpu-mhz` | u16: `90`, `180` or `360` | `90`, the bootloader default | -| `-Desp32p4-port` | serial port the board is wired to | `/dev/ttyUSB0` | -| `-Desp32p4-prof` | bool; per-phase cycle counts for every frame | `false` | -| `-Desp32p4-firmware` | bool; also build the flashable image and its board steps | `false` | - -The five `-Desp32p4-*` options that are not `-Desp32p4-firmware` are registered -unconditionally rather than inside the `-Desp32p4-firmware` block that consumes -them, so `zig build --help` lists them and passing one without the flag is not -an "unknown option" error. + +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 diff --git a/docs/design.typ b/docs/design.typ index 9a92f7ee..8a5b9381 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1,9 +1,4 @@ -// Diagrams come from cetz, which is the one thing here that is genuinely -// painful to hand-roll. Pinned to the version in the local package cache so -// this document builds offline; `typst compile docs/design.typ docs/design.pdf` -// must never need the network, because the PDF it produces is a test fixture -// (src/pdf.zig and src/pdf_pane*.zig open it, and `zig build mupdf-check` -// renders and searches it). +// docs/design.pdf is a retained test fixture, not regenerated by normal builds. #import "@preview/cetz:0.5.1" #set page(paper: "a4", margin: (x: 1.5cm, y: 1.8cm), columns: 2, numbering: "1") @@ -15,10 +10,6 @@ #show heading.where(level: 2): set text(size: 9.2pt) #show heading.where(level: 3): set text(size: 8.8pt, style: "italic") -// Listings are styled NATIVELY rather than through a package. codly is the -// obvious choice and the cached 1.2.0 does not compile under typst 0.15 — it -// still calls the pre-0.13 `pattern` — and a document that cannot be rebuilt is -// worse than one with plainer listings. Six lines buy independence from that. #show raw.where(block: true): it => block( width: 100%, fill: luma(246), @@ -33,9 +24,6 @@ #set figure(gap: 0.55em) #set table(stroke: 0.4pt, inset: 0.35em) -/// A figure that spans BOTH columns, for the diagrams and listings that will -/// not survive being folded into 8 cm. Floats to the top of a page, which is -/// where a reader expects a wide figure to be. #let wide(caption: none, body) = place( top, scope: "parent", @@ -44,9 +32,6 @@ figure(body, caption: caption), ) -/// Diagram labels are code more often than they are prose, and the inline-raw -/// rule above sizes for body text. Every canvas below is wrapped in this so a -/// symbol name inside a box does not outgrow the box. #let diagram(body) = [ #show raw.where(block: false): set text(size: 5.9pt) #body @@ -70,8 +55,8 @@ prototype had three parallel implementations of one editor. Two seams carry that. Outward, the core is a state machine over plain values: `Event` in, `Surface` and a queue of `Effect` out, and nothing that cannot - be said in those types exists in pardes. Downward, `host.VTable` is - twenty-one optional function pointers, every null one answered in-process, + be said in those types exists in pardes. Downward, `Host.VTable` is + optional function pointers, with in-process fallbacks, so the zero-method host is both the test harness and the browser. A session can also be a daemon: the core keeps the ptys and the disk and N frontends carry only a screen, a keyboard and a clipboard. @@ -106,7 +91,7 @@ instance's dump is a first-class feature of the one application, and the web shell is that application with an embedded dump and `spawn` left unanswered. Same core, no viewer fork. -The other acme mechanism is a control filesystem, `src/acmefs.zig` +The other acme mechanism is a control filesystem, `src/fs.zig` (@fs). == One core, five shells @@ -135,12 +120,12 @@ The five, and what each one actually is: the terminal (libvaxis); a native SDL3 window (the steamdeck); the browser (a freestanding wasm core driven by vanilla JavaScript and rendered as HTML/CSS); a native macOS app (an AppKit and CoreText shell over a static `libpardes.a`); and an ESP32-P4 microcontroller, a -freestanding riscv32 *object* that the `zig_p4` package links beside its own +freestanding riscv32 *object* that the sibling `05-zig-p4` toolchain links beside its own `_start`, linker script and UART driver (`src/esp32p4.zig` header; the `pardes-esp32p4` object `build.zig` emits). `pardes.Platform` is `enum { tty, gui, web, macos, esp32p4 }`. -Native shells share `shell_bin`'s OSC 133 startup snippets, but not their +Native shells share `host_io.Shell`'s OSC 133 startup snippets, but not their files. Each host owns a private `mkstemp` pair for its lifetime, writes and closes both before the first fork, passes those unpredictable paths directly in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS @@ -154,19 +139,17 @@ files. session can carry a terminal and an SDL window at the same time and outlive both. `main.nativeMain` dispatches `--detach` before it switches on the platform, because it is not a shell: the tty and gui builds can both be asked -for one. The two flags together are refused there rather than resolved by -declaration order, and so is `--fs` beside either of them — only a LOCAL session -mounts the control filesystem, so `--fs --detach` used to be parsed, stored and -served by nobody. Both flags take `=name` and never a separate word, so +for one. The two flags together are refused. Local and detached sessions both +serve 9P by default. Both flags take `=name` and never a separate word, so `pardes --attach README` opens `README` in a fresh session instead of attaching to one called `README`. See @detached and `docs/detached.md`. = The seam #wide(caption: [The core/shell seam. `Event` is the only way in; `Surface` and a -queue of `Effect` are the only ways out. `host.VTable` is how an `Effect` +queue of `Effect` are the only ways out. `Host.VTable` is how an `Effect` reaches a resource, and every method a host leaves null is answered by -`host.Fallback` inside the same process.])[ +`host_io.Fallback` inside the same process.])[ #diagram[ #cetz.canvas(length: 0.995cm, { import cetz.draw: * @@ -212,15 +195,15 @@ reaches a resource, and every method a host leaves null is answered by // ---- the vtable ---- rect((6.0, -1.0), (11.6, 0.4), name: "vt") - content((8.8, 0.14), text(7.0pt)[`host.VTable` --- 21 optional methods]) - content((8.8, -0.32), text(6.0pt)[`push_` #sym.arrow.r every host, returns nothing]) - content((8.8, -0.68), text(6.0pt)[`pull_` #sym.arrow.r exactly one host answers]) + content((8.8, 0.14), text(7.0pt)[`Host.VTable` --- optional callbacks]) + content((8.8, -0.32), text(6.0pt)[one host owns each core]) + content((8.8, -0.68), text(6.0pt)[null callbacks use core fallbacks]) line((8.8, 0.9), (8.8, 0.4), mark: (end: "stealth")) line("vt.east", (13.3, 1.5), mark: (end: "stealth")) // ---- fallback ---- rect((0, -1.0), (4.3, 0.9), name: "fb") - content((2.15, 0.62), text(7.0pt)[`host.Fallback`]) + content((2.15, 0.62), text(7.0pt)[`host_io.Fallback`]) content((2.15, 0.18), text(6.0pt)[every null method, answered here:]) content((2.15, -0.18), text(6.0pt)[a virtual filesystem over the]) content((2.15, -0.52), text(6.0pt)[embedded source, a virtual]) @@ -316,7 +299,7 @@ pub const Effect = union(enum) { theme_file: struct { generation: u32, on: bool }, dump_themes: struct { pane: u8 }, - fs_reply: acmefs.Reply, + fs_reply: filesystem.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, @@ -408,27 +391,24 @@ for a pty; everything else queues O(1) effects per event, and the input queue holds at most 64 events per pump, so 128 leaves two effects per queued event. A build with no terminal panes has no pty to write to at all. -== `host.VTable`: who serves the core +== `Host.VTable`: who serves the core -The seam itself is one struct of twenty-one optional function pointers -(`host.VTable`): the `std.mem.Allocator` shape, and the generalization of -two vtables this codebase already grew on its own: the tty shell's -terminal-query hook, which this replaced, and `macos.Runtime`, which survives as -the C ABI that shell's host still enters through. +`host_io.Host` holds a context pointer and optional callbacks. macOS enters +its native shell through the separate `Runtime` C ABI. ```zig pub const VTable = struct { /// The ONLY place the process may sleep. - pull_wait_input: ?*const fn ( + wait_input: ?*const fn ( ctx: ?*anyopaque, timeout_ms: u32, ) void = null, - push_present: ?*const fn ( + present: ?*const fn ( ctx: ?*anyopaque, surface: *const pardes.Surface, ) void = null, // ... - pull_tty_taken: ?*const fn ( + tty_taken: ?*const fn ( ctx: ?*anyopaque, pane: u8, ) bool = null, @@ -437,17 +417,16 @@ pub const VTable = struct { ``` A null method is not an error: the core substitutes a default backed by ordinary -data structures in the same process (`host.Fallback`). A `Save` lands in a real +data structures in the same process (`host_io.Fallback`). A `Save` lands in a real file under the tty host and in `Fallback.files` under a host that never wrote a filesystem method, and every path above that behaves identically. Two consequences were taken on purpose. The zero-method host IS the test harness: a `Host{}` is a complete, deterministic, in-process pardes with a virtual filesystem, a virtual clipboard and silent ptys. And `Fallback` lives on the -`Pardes` instance rather than on the host, so N cores driven by one fan-out host -each keep their own state and can run in parallel. +`Pardes` instance rather than on the host, so cores keep independent state. The fallback filesystem is not empty. It is pardes's own source, embedded -(`src/source_manifest.zig`), with `files` holding only what this session WROTE, +(`src/fs.zig`), with `files` holding only what this session WROTE, so a Save shadows the built-in copy and reading it back returns the edit. That is what makes a host with no file methods a usable pardes rather than one staring at an empty buffer. @@ -472,46 +451,11 @@ platforms, which is exactly the pair `main.zig` accepts `--attach` for: the same question asked at the command line instead of in a tag. A capability that a build cannot serve should not be a word that build offers. -=== The naming rule is compiler-enforced +=== Callback ownership -Every method name says how a fan-out must route it, and `Fanout.isPull` makes a -wrong name a compile error rather than a silent push. - -#wide(caption: [`host.Fanout.isPull`. The failure of a forgotten `pull_` is -invisible in every unit test and obvious only to the user: one Ctrl-V pasting -twice. `Fanout.all` synthesizes one wrapper per field, so adding a method to -`VTable` needs no code in the fan-out at all.])[ -```zig -/// How to route a method, read off its own name. A method that is neither -/// is a COMPILE ERROR rather than a silent push, because the failure of a -/// forgotten pull is invisible in every unit test and obvious only to the -/// user: one Ctrl-V pasting twice. -fn isPull(comptime name: []const u8) bool { - if (std.mem.startsWith(u8, name, "pull_")) return true; - if (std.mem.startsWith(u8, name, "push_")) { - if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ - name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); - return false; - } - @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ - "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); -} -``` -] - -`push_` reaches every wrapped host and returns nothing — a push with an answer -would have N answers and no way to pick one. `pull_` is served by exactly one -host, because there is one of whatever comes back: one value, one sleep that -ends, one `Event.paste` for one Ctrl-V, one `lsp_resp` per request id. - -The six `pull_` methods are therefore exactly the six places a single answer -exists: `pull_wait_input` (the one place the process may sleep), -`pull_tty_taken`, `pull_gpio_toggle`, `pull_read_clipboard`, `pull_lsp` and -`pull_pipe`. `pull_tty_taken` is a pull and not a pushed fact because the -`execute` that asks — has a program taken this pane's tty? — must choose a -destination inside its own update, and effects drain after; a pushed fact would -mean every host probing every pane's processes every frame to answer a question -asked when a human middle-clicks a word. +Each core has one host. The detached host explicitly broadcasts shared state +and routes clipboard reads and link opening to the originating frontend. +There is no name-based fan-out layer. == The frame, as a frontend writes it @@ -519,7 +463,7 @@ asked when a human middle-clicks a word. pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.pull_wait_input) |f| + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); @@ -527,12 +471,12 @@ pub fn pump(p: *Pardes, h: Host) !void { // A quitting frame has already freed // what it would draw. if (p.quit) return; - if (v.push_poll_frame) |f| f(h.ctx); + if (v.poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render( p.frame_arena.allocator()); - if (v.push_present) |f| f(h.ctx, surface); - if (v.push_post_present) |f| f(h.ctx); + if (v.present) |f| f(h.ctx, surface); + if (v.post_present) |f| f(h.ctx); // ... } ``` @@ -543,12 +487,12 @@ then every queued event, to completion; then every effect, to completion, including effects `perform` queued; then one arena reset and one render; then present, then post-present. -Animation time is not spent in here. `pull_wait_input` was told how long it may +Animation time is not spent in here. `wait_input` was told how long it may sleep, and a display clock wakes faster than that on input, so only the host knows when a real frame interval has passed. Each spends it by handing back one `.tick`. -`push_post_present` is split from `push_present` because it must observe a frame +`post_present` is split from `present` because it must observe a frame the user has actually seen: panel-presentation acknowledgement and pointer refresh both depend on that, and a hook that ran before the pixels landed would acknowledge a frame that was never shown. @@ -581,14 +525,10 @@ The core's one `pointerOperand` primitive owns click-word expansion and is share 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. -Pane implementations are similarly flat and direct: `file_pane.zig`, -`term_pane.zig`, `image_pane.zig`, `output_pane.zig`, and `pdf_pane.zig` own -their kind-specific storage and operations. `pardes.zig` keeps the layout, input -dispatch, cross-pane invariants, and the small calls joining those modules. -There is no pane vtable or callback layer; the kind is already plain data, so a -direct switch/call is the shortest boundary. The large end-to-end PDF cases live -in `pdf_pane_integration_test.zig`, keeping pane-specific fixtures and raster -assertions out of that core file as well. +`panes.zig` keeps each pane kind's storage and operations together. +`layout.zig` owns placement and presentation state. `pardes.zig` handles input +and cross-pane state directly, without a pane vtable. Integration fixtures live +in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`. = State @@ -600,57 +540,30 @@ grow with what you open; everything else is sized at init. ```zig Pardes - ncol + col_weight[6], col_terms[6][16], col_n[6] - panes: [16]?*Pane // slot array; id = index - active: usize - drag: Drag // none | border_v | - // border_h | move | - // tag | select - settings: runtime_config.State - rects: [16]Rect // where each pane - // landed, this frame - panel_tracks: [16]?Track // live panes, - // serial-guarded - presented_panel_tracks:[16]?Track - closing_panel_tracks: [16]Track // dense visual - // tombstones, no owner - presented_cells + panel_cell_diffs + ncol + col_weight[6], col_panes[6][16], col_n[6] + panes: [16]?*Pane + active + drag + config.Runtime + rects: [16]layout.Rect + presentation: layout.Presentation effects: [limits.effect_cap]Effect + head/len in_q: [64]Event + head/len - fallback: host.Fallback // per instance - fs: acmefs.State // inert until --fs + fallback: host_io.Fallback + fs: fs.Namespace Pane - tag: TagLine // live prefix (cwd/path) - // + editable tail - vt, stream: ghostty-vt Terminal and its stream — - on EVERY pane, not just terminals, - which is what lets the same keys and - the same parity suite drive a file - and a shell - file: ?file_pane.State - image: ?image_pane.State - pdf: PdfSlot // payload presence is the - // kind; none = terminal - vweight: f32 - mode: enum { normal, insert, tty } - cursor: absolute body position // rides the - // scrollback, not the screen - msel/vsel + sels[63] + nsel // the primary - // range, and up to 63 more - ovl: ?term_pane.EditBuffer // ONE typed run, - // anchored to an absolute row - undo: two stacks, not one — term_pane.Snapshot - history for an edit buffer, and - file_pane.State history for file content - -file_pane.State = path + bytes + line index - + Syn (tree-sitter bytes) -image_pane.State = decoded RGBA + petscii cache -pdf_pane.State = MuPDF document + continuous - layout + search/selection/outline - + bounded per-page raster relay - + frame placement decisions + serial + mode + vweight + terminal: ?*panes.Terminal.State + file: ?panes.File.State + image: ?panes.Image.State + pdf: PdfSlot + cwd: none | inherited(*Pane) | owned([]u8) + tag_tail + prompt + cursor + selections + ovl: ?panes.Terminal.EditBuffer + +Terminal.State = VT + stream + replay + reply +File.State = path + bytes + line index + syntax + undo +Image.State = decoded pixels + render cache +Pdf.State = document + layout + raster cache + search ``` `MAX_PANES` is 16 and `MAX_COLS` is 6 (`pardes.MAX_PANES`, `pardes.MAX_COLS`). Sixteen panes @@ -777,11 +690,11 @@ does not touch. == Runtime settings are one table -User-settable runtime choices are one plain `runtime_config.State`: booleans, +User-settable runtime choices are one plain `config.Runtime`: booleans, theme index, owned bounded shell/font strings, requested/effective font facts, one panel-transition enum, and scene-effect booleans -(`runtime_config.State`). A compile-time `settings` array -(`runtime_config.settings`) generates each setting builtin and the rows of the +(`config.Runtime`). A compile-time `settings` array +(`config.Runtime.settings`) generates each setting builtin and the rows of the single `Config` query. It has no callbacks and no parallel query registry to drift from it. @@ -871,7 +784,7 @@ selectable, yankable and executable but READ-ONLY: every edit op measures from `tag_tail` is a fixed `[max_tag_tail]u8`, and the bound IS the storage (`limits.max_tag_tail`): every writer — `appendTag`, `tagInsert`, -`restoreDumpTail`, the acmefs `tag` file — refuses input that does not fit +`restoreDumpTail`, the 9P `tag` file — refuses input that does not fit rather than truncating it. The schema limit and the buffer therefore can never disagree, which is what lets a dump reader reject data before copying it into a pane. @@ -884,10 +797,10 @@ clicking a tag never changes a pane's mode. == Eleven transitions -`panel_animation.zig` is backend-neutral data and math: the transition +`layout.zig` is backend-neutral data and math: the transition vocabulary, easing, exact endpoint progress, stable per-cell noise, and a POD track. `Transition` has twelve members counting `off` -(`panel_animation.Transition`), with explicit numeric values because they +(`layout.Transition`), with explicit numeric values because they cross both GUI shader ABIs — GLSL receives the enum in an instance `uvec4` and the Core Image kernel receives it as a float, so spelling the numbers keeps a source reorder from changing pixels. @@ -963,7 +876,7 @@ An `.ascii` diff is a `{ from: u8, to: u8 }` pair, and the core composes it by incrementing or decrementing the printable byte. Short walks move one value per frame; a longer walk is crossed by eased character skips and finishes within `ascii_max_movement_frames`, which is 12 -(`panel_animation.ascii_max_movement_frames`). Frame +(`layout.ascii_max_movement_frames`). Frame zero is the exact old byte and the endpoint is exact, so an intermediate frame is always valid UTF-8. `Track.frame_count` carries the core-computed duration for these data-dependent effects — zero selects the effect preset, and the ASCII @@ -997,11 +910,9 @@ DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes neither effect family. -`EffectCode ` writes the actual backend math, host submission, and -shader or grid source segments embedded by the build into an ordinary output -pane. This makes the implementation inspectable after installation and makes -sharing explicit: several builtins can quite honestly print the same shader with -different uniform bits. +`EffectCode ` lists the current backend's build-embedded source paths +under `/virtual`. Look opens each full file without a source checkout. +Shared implementations share paths. == The ASCII fast paths @@ -1039,7 +950,7 @@ configuration — 40×12, no tree-sitter — which was the largest single item t `\t`, `\r`, the C0 controls and DEL are excluded by the range test and keep their existing handling. -The second is `file_pane.fitEnd` (`file_pane.fitEnd`), which decides +The second is `panes.File.fitEnd` (`panes.File.fitEnd`), which decides where a soft-wrapped row breaks. It asks `modal.nextGrapheme` and `graphemeDisplayWidth` once per character, and a 640-column line asks 640 times. @@ -1133,8 +1044,8 @@ from `src/detached/server.zig:34-59`.])[ row(-0.92, text(5.6pt)[`read_clipboard`'s answer is not a reply message: it comes back as an ordinary `Event.paste`]) row(-1.40, text(5.6pt)[the gaps `0x13`..`0x17`, `0x1e` were `output` `eof` `lsp_resp` `pipe_resp` `file_changed` `tick`: deleted, not renumbered,]) row(-1.78, text(5.6pt)[when the daemon took the disk --- a decodable `output` let an attached peer forge a pane's text]) - row(-2.16, text(5.6pt)[never on the wire: `pull_wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s]) - row(-2.56, text(5.6pt)[that answer their own caller, and `push_fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for]) + row(-2.16, text(5.6pt)[never on the wire: `wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s]) + row(-2.56, text(5.6pt)[that answer their own caller, and `fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for]) }) ] ] @@ -1159,10 +1070,8 @@ detach, kill the terminal, attach from another one, and the build that was running in pane 3 is still running and has been scrolling into the core the whole time. -Of the twenty-one `VTable` methods, the detached core's `Session` implements -seventeen and leaves four null: `push_post_present`, `pull_gpio_toggle`, -`pull_lsp`, `pull_pipe`. It mounts its own `/dev/fuse` and polls it in the same -`poll(2)` as its frontends, so `--fs` works in a daemon and needs no thread. +The detached core also serves the default 9P socket. Its event loop drains +filesystem transactions alongside frontend and terminal input. Nothing blocks indefinitely, and that property is what a detached session is *for*. Every descriptor is non-blocking; the single `poll(2)` is the only place @@ -1238,17 +1147,10 @@ path, because neither of them writes anything. effects, because it is not an effect the session performs on the world: it is one frontend being told it is done. -Four `VTable` methods are named in the file with their reasons for *not* being on -the wire. `pull_wait_input` IS the server's poll loop. `push_poll_frame` and -`push_post_present` carry no information — `frame` already arrives exactly once -per pump at the same place in the order, so two more messages per frame per -client would say nothing the frame does not. `pull_tty_taken`, -`pull_gpio_toggle`, `pull_lsp` and `pull_pipe` are answers the caller waits for -or work dispatched off the loop, and a round trip inside `update` is the one -thing this transport must never do. `push_fs_reply` cannot be a broadcast at all: -the transport that asked is the one holding the request, so with N frontends, -N−1 would receive the answer to a request they never made — which is why the -acme mount stays in the detached process and `Event.fs_req` has no `ClientTag`. +Host polling, presentation, process queries and worker dispatch stay local to +the session owner. They are not frontend wire messages. The owner also serves +9P: each filesystem reply returns to its requesting connection, not to attached +frontends. `Event.fs_req` therefore has no `ClientTag`. === The codec is architecture- and build-neutral @@ -1272,11 +1174,8 @@ by a build with nowhere to put it. `version` is a `u16` checked on connect and refused loudly, because two builds of pardes are routinely on one machine — `zig build` replaces the binary under a running session — and a frontend decoding another version's frame layout would -paint garbage and blame the terminal. `ClientTag` is exhaustive on purpose, which -is the opposite of `fuse.zig`'s `Opcode`: there a newer *kernel* adds opcodes, -and a non-exhaustive enum is the only way to receive one without undefined -behaviour, whereas here both ends are pardes and an unknown tag is a corrupt or -hostile stream. +paint garbage and blame the terminal. `ClientTag` is exhaustive: both ends are +pardes, and an unknown tag is not a valid message in this protocol version. `max_payload` is 16 MiB, derived rather than chosen: a full frame of the largest grid this protocol admits (512×128) at a worst case of one run per cell is @@ -1386,14 +1285,13 @@ in order to. == An object, not a module `-Dplatform=esp32p4` emits ONE freestanding riscv32 object exporting the C ABI in -`src/esp32p4.zig`; the `zig_p4` package links it beside its own `_start`, its +`src/esp32p4.zig`; the sibling `05-zig-p4` toolchain links it beside its own `_start`, its generated linker script, and its UART driver (the `pardes-esp32p4` object `build.zig` emits). Not an executable, because the entry point is over there. Not a library, because `addLibrary` bundles a `compiler_rt` the firmware already has. -And not a module exposed through `build.zig.zon`, which is what it was first and -is the interesting part. A dependency in the OTHER direction was built and +Historically, it was a module exposed through `build.zig.zon`. A dependency in the OTHER direction was built and reverted: nesting this package's roughly 30-package graph under `zig-p4`'s broke every build in that repo, not just the firmware one. `std/Build.zig:2091` exceeded its @@ -1401,18 +1299,16 @@ exceeded its seven cached tree-sitter versions failed to compile because their `build.zig` uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files into a repo whose entire claim is that Zig is its only dependency. This direction -is free: `zig_p4` declares no dependencies at all, so it enlarges nothing here, -and its `build()` early-returns when it is not the root. - -The seam stays bytes over eight C functions, because bytes are the right seam for -a serial line and because that is the arrangement the board was measured -through. `-Desp32p4-firmware` adds the other half in this tree — the firmware -executable rooted at `src/esp32p4/app.zig`, the flashable image, and the steps -that write it to a board and talk to it — and the image it produces is -byte-identical to the one the toolchain repo produces from the same sources. It -is opt-in and not out of timidity: `esp32p4.firmware()` reads ESP-IDF's register -headers at CONFIGURE time and exits non-zero when there is no checkout, so a bare -`-Dplatform=esp32p4` must not call it. +was possible because `zig_p4` declared no dependencies of its own. The current +editor build no longer imports that package; the firmware toolchain is separate. + +The C ABI carries terminal bytes. After building the object here, `zig build +-Dpardes` in `../05-zig-p4` links `src/esp32p4/app.zig` into the firmware image. +That toolchain needs ESP-IDF register headers; the local object build does not. +The standalone GPIO 9P image instead uses `zig build +-Dapp=../02-pardes-code/src/esp32p4_9p.zig` there. It does not link the editor: +`src/esp32p4_gpio.zig` holds its fixed namespace and `src/esp32p4_9p.zig` drives +the UART protocol loop. The object is also the compile probe. Rooted at `src/esp32p4.zig` it drags the whole core through the riscv32 backend by actually calling it, so `llvm-size` on @@ -1430,12 +1326,14 @@ any other input, because firmware has no `TIOCGWINSZ`. == The memory budget is one table -`src/limits.zig` is every board-shaped capacity in one place. These numbers used +`src/memory.zig`'s `limits` contains the board-shaped capacities. These numbers used to be nine `platform == .esp32p4` tests scattered across nine files, each one a separate place to forget — and they are not nine decisions. They are ONE decision, how much memory this build is allowed to spend, taken nine times where no reader could see the total. +The original budget table below is historical; `memory.limits` is authoritative. + ```zig /// `board` is the ESP32-P4 firmware's budget: /// a 384 KiB heap and a 240 KiB chunk of L2MEM @@ -1519,7 +1417,7 @@ pub const arena = struct { ] What does NOT belong in this table is capability switches. `terminal_panes`, -`board_memory.enabled`, `hosted` and `font_picker` answer "does this build have +`builtins.Board.enabled`, `hosted` and `font_picker` answer "does this build have the thing at all", which is a question about the platform and not about a budget, so they stay next to the thing they gate. @@ -1554,84 +1452,58 @@ megabytes against a 1.5 MiB partition (`build.zig:298`). MuPDF is refused for th same reason (`build.zig:297`). The board gains four words nothing else has, all gated on -`board_memory.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and +`builtins.Board.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and `Gpio`. Each takes an address or a pin, so none can have a leader path — a key path names a builtin and can never carry an operand. `Gpio` is the one that goes through the host seam (@seam) rather than reaching the registers directly, and -`pull_gpio_toggle`'s comment says why: driving a pad correctly is not one +`gpio_toggle`'s comment says why: driving a pad correctly is not one register. It is the IO MUX function select, the GPIO matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's own headers on the die; a second copy in the core would be a second copy nobody tests. -`board_memory.zig` makes the target the *witness* rather than the gate: `enabled` +`builtins.Board` makes the target the *witness* rather than the gate: `enabled` is keyed on the platform, and a `comptime` block then refuses to compile if that platform is hosted, is not freestanding, or is wasm — because whatever else `esp32p4` means, it has to still be a machine whose addresses are the bus's -(`board_memory.enabled`). +(`builtins.Board.enabled`). = The control filesystem -== acmefs as a pure transaction - -plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, `body`, -`ctl`, `data`, `event`, `tag`, and a program that opens those files IS an editor -extension — no plugin API, no embedded interpreter, no rebuild. `pardes --fs` -serves the same tree over Linux FUSE (`src/fuse.zig`), and `src/acmefs.zig` is -the whole of what the files MEAN. - -A filesystem is a request/response protocol driven by other processes, which is -exactly the kind of concurrency the core does not have and must not grow. acme -answers it with a thread per in-flight request — `xfidallocthread`, a `Channel` -per `Xfid`, a `QLock` per window. pardes cannot and should not, so `acmefs.zig` -is a pure main-thread transaction, `handle(p, req) Reply`: no thread, no waiting, -no callback, no allocation on the hot path, freestanding-safe, and unit-testable -with no FUSE anywhere near it. - -Requests arrive as an ordinary `Event.fs_req` and answers leave as an ordinary -`Effect.fs_reply`, so the transport is the queue every other host/core message -already uses. A backend with no threads at all is not a special case: it either -never sends a request, or sends one from its own frame loop. And acme's blocking -`event` read — which waits for the user to do something, parking the `Xfid` in -`w->eventx` until a later `winevent` sends it a message — is `Status.again` here: -"nothing consumed, ask me again". The waiting lives in the host, which is where -the kernel's request already is. The core keeps no waiter list and no wakeups. - -One divergence from acme is deliberate: acme counts RUNES, pardes counts BYTES, -clamped to grapheme boundaries. Every offset in this filesystem — `addr`, `data`, -the event records' q0/q1, `index`'s lengths — is a byte offset, because pardes is -byte-addressed end to end (selections, look spots, LSP offsets) and a second -coordinate system would mean an O(n) conversion at every boundary and a lossy -`addr=dot`. acme pays that cost the other way round: it keeps the document as -`Rune*` and converts on every utf read, in a function that carries a -"BUG: stupid code: scan from beginning" comment for its cache miss. The two agree -for ASCII, which is what scripts compute with. - -`Pardes.fs` is `acmefs.State`, zero-initialised and inert: a core nobody scripts -pays one branch per edit and nothing else. - -== The nested socket - -`src/nested.zig` listens on `/pardes-.sock`, where `` is -`$XDG_RUNTIME_DIR` — a per-user 0700 tmpfs the login session already cleans up — -or `~/.local/state/pardes` when the session has none. It accepts exactly one -verb, `Look [:]`, and nothing else, which is the security property. -That is how a `pardes ` run inside a pardes hands the file to the outer -session instead of stacking a second full-screen UI inside one of its panes. - -It arrives as an `Event.command` rather than a direct `executeBuiltinLine` call -so that it gets the trailing sync and the ordinary effect drain: `Look` on a -directory emits a `.spawn` the shell has to perform. - -The detached sockets live in the same per-user directory under a different name, -`pardes-detached-.sock`, and both sides derive the path from one predicate -in one file so that the side which binds and the side which connects cannot -disagree (`server.socketPath` and `server.sessionPath`). A frontend that finds a socket at -a derivable path which is not a private one of ours refuses with `NotPrivate` -rather than reporting "no session": a planted socket collects every keystroke -typed into the frontend that trusts it, and the two cases need different answers -from a human. +== Namespace and transactions + +`src/fs.zig` owns Look resolution and the editor's file interface. An ordinary +Look checks the OS first, then the virtual tree. `/n/os` and `/n/self` select +those mounts explicitly; `/virtual` names the embedded and self-reflecting tree. +Named remote mounts live under `/n/` and retain their identity through Save. + +Every native session opens a 9P2000 Unix socket. The wire root exposes `os` and +`self`, without the editor's `/n` prefix. `self/pane/` contains `body`, +`tag`, `ctl`, `addr`, `data`, `event`, and selection files. Offsets are UTF-8 +bytes. `self/screen` freezes rendered cells and styles for the lifetime of an open. +See `docs/fs.md` for the public paths and commands. + +`src/9p.zig` implements the protocol without OS dependencies; `src/9p_io.zig` +owns native sockets and the client. Requests enter through `Event.fs_req`, and +`Effect.fs_reply` carries replies. A pending event read returns `Status.again`; +the native listener owns waiting and retries. Filesystem mutation runs on the +same thread as editing. + +== Nested Look + +Pane shells inherit `PARDES_9P`, `PARDES_PANE` (the pane serial), and +`PARDES_FORWARD_LOOK`. A child launch resolves its OS-relative argument in +the child's working directory, then writes `look ` to the parent's +`self/pane//ctl`. Explicit `/virtual` and `/n` paths resolve in the +parent. The ordinary filesystem update performs layout and drains host effects. + +`--nested` starts a separate editor and disables forwarding from its direct +pane shells. Its 9P socket remains available for control and plugins. There is +no executable-name discovery or separate Look listener. + +Detached frontends use `pardes-detached-.sock` in the same runtime +directory. The shared Unix socket conventions live in `src/9p_io.zig`. = Build @@ -1694,7 +1566,7 @@ is the only thing two of them are allowed to disagree about. Line numbers are out(2.12, [`pardes-gui`], [`-Dplatform=gui` --- SDL3, a FreeType atlas, SPIR-V]) out(1.72, [`pardes.wasm`], [`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=`, plus a vanilla DOM shell]) out(1.32, [`libpardes.a`], [`-Dplatform=macos`, then the `macos-app` step #sym.arrow.r `pardes.app` (Darwin host)]) - out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4` (target forced, `:171`); `-Desp32p4-firmware` adds the image and the board steps]) + out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4`; the sibling `05-zig-p4` toolchain builds the firmware image]) row(0.46, text(5.6pt)[a bare `zig build` emits the FIRST TWO together, because those two are what installing pardes means; every other spelling is one invocation each]) row(0.18, text(5.6pt)[beside them, from the same graph: `pardes-isolate` (tty only --- the filesystem is not compiled in) and `hxdiff`'s headless core]) @@ -1758,7 +1630,8 @@ Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the web and the board, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), zig-tree-sitter with 29 grammars for 28 languages (markdown takes two, block and -inline), and `zig_p4` — all pinned through `zig fetch` and wired in `build.zig`. +inline) — all pinned through `zig fetch` and wired in `build.zig`. The board's +`05-zig-p4` firmware toolchain is a separate sibling checkout. Generated during the build: `highlights.scm` into an options module; the vendored helix and zed theme sources into the generated half of the theme ring; @@ -1932,9 +1805,9 @@ over CDP with real DOM pointer and touch events and diff `test/web-snapshots/`; `image-harness` and `pdf-harness` snapshot native PIXEL output through kitty graphics and SDL; `macos-e2e` is an offscreen AppKit snapshot suite over its own seven scripts (`test/macos-snapshots/`: boot, cwd, drop, font, keys, rotate, -trackpad). `esp32p4-test` runs an on-die suite on real hardware, and -`esp32p4-image-size` and `esp32p4-image-check` answer the flash-budget question -with no board attached. +trackpad). In the sibling `05-zig-p4` toolchain, `zig build selftest` flashes and +runs `src/esp32p4/selftest.zig` on real hardware. The local freestanding object +and the standalone GPIO 9P image can both be compiled without a board attached. Two suites are differential rather than golden: `hxdiff` compares the core's motion and operator results against helix case by case, and `hxparity` compares @@ -1970,10 +1843,6 @@ plugin system, because the control filesystem is the extension point (@fs) and needs no API of its own. No async runtime in the core — the shells may thread, the core is single-threaded by construction. -No 9P. The library boundary is exactly where acme put the file server, and the -FUSE mount is already that server with a Linux transport instead of a 9P one; a -sixth shell could serve `Surface` and `Event` over 9P without touching the core. - "No config files" held until the startup file (`docs/config.md`) arrived, and that is the narrowest thing the phrase could still cover: a list of builtin COMMANDS run before the first frame — no schema, no new vocabulary, and no key remapping. @@ -2080,14 +1949,11 @@ selection, the document outline into `+PdfSections`, fit-width/fit-height and a themed duotone tint — the last three named in the pane's own live tag. Tutor: embedded text as file pane. -*Language.* ZLS compiled in and called IN-PROCESS — no subprocess, no -JSON-RPC, no daemon and nothing cached between queries — behind a -one-function seam (`lsp.query`) so that swapping a backend touches nothing -else. `.zig` only, and on demand only: helix's five `g` gotos, ten builtins -under `SPC l`, `]d`/`[d`, `=`, and insert-mode Tab after a `.`. Answers become -rows in `+Search`, `+Hover` or `+Lsp` — output buffers of the same kind `/`, -Find and Help fill — or, for a rename, byte ranges the core applies in one undo. The -web shell has no threads and therefore no backend at all. +*Language.* The native host snapshots each request and runs it outside the UI +loop. Zig uses the in-process ZLS backend; configured external language servers +use the JSON-RPC client. Results become output rows or edits, applied only while +the request's pane and revision still match. Web and board builds have no +language backend. See `docs/lsp.md` for configuration and commands. *Chrome.* One theme per `.zig` file, folded into the ring at comptime: ours first — helix (default; near-black page, chrome one grey-ramp step off it, @@ -2119,14 +1985,14 @@ starting point without adding inheritance or a second theme vocabulary. Colors toggles all recolor passes; Debug stats overlay; Dump writes state ZON. Eleven panel transitions and three scene bits are settings, one builtin each, generated from -`runtime_config.settings`. +`config.Runtime.settings`. *Session.* `--detach[=name]` runs a core with no terminal; `--attach[=name]` makes a thin frontend over a unix socket; `Attach [name]` (`SPC s a`) hands a running frontend's screen to a detached core, connecting before it swaps; `Detach` (`SPC s D`) leaves a session that carries on. Up to 32 frontends on one session, all showing the same screen; the pane shells belong to the daemon and -outlive every frontend. `--fs` serves the acme control tree over FUSE; a nested +outlive every frontend. The default 9P socket serves the control tree; a nested `pardes ` hands its argument to the outer session over the per-pid socket. `pardes --version` prints the manifest version and the commit it was configured from. diff --git a/docs/detached.md b/docs/detached.md index 0902f78a..9b9ea4ef 100644 --- a/docs/detached.md +++ b/docs/detached.md @@ -1,332 +1,57 @@ # Detached sessions -One pardes core with no terminal of its own, and any number of thin frontends -attached to it over a unix socket. The core holds every piece of state — the -text, the undo history, the layout, the pane shells — and outlives every -frontend that comes and goes. +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. -The model is the ESP32-P4 serial console, which is why it is worth naming. On -the board, pardes runs as firmware and the host side is a dumb wire: keystrokes -in, bytes out, and the console performs no effects at all. A detached session is -that arrangement with a typed wire instead of raw ANSI: input in, cells out, and -a frontend that does almost nothing. +```sh +pardes --detach=work & +pardes --attach=work +pardes-gui --attach=work +``` -## Using it +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. - pardes --detach # a core named by this process's pid - pardes --detach=work # ...named `work` - pardes --attach # become a frontend of the one session there is - pardes --attach=work # ...of `work` - pardes-gui --attach=work # the SDL window is a frontend too +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. -And from inside a running editor, as ordinary acme words — type one in a tag and -execute it, or press its leader chord: +## Files and transport - Attach # hand this window to the session there is - Attach work # ...to `work` (chord: SPC s a) - Detach # leave the session, and leave it running - # (chord: SPC s D) +The frontend socket is `pardes-detached-.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`. -`Attach` switches **in place**: the window, the terminal and the process stay, -and what changes is where the state lives. The ordering is the feature — see -[The in-place switch](#the-in-place-switch). +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). -`Detach` is its counterpart and the smaller of the two: only the frontend that -ran the word leaves. The session, its pane shells and every other attached -frontend are untouched, so leaving is a success — the terminal prints where to -come back to and exits 0. It routes ORIGIN-ELSE-PRIMARY, the same rule -`read_clipboard` takes, because it answers something one particular human just -did. +## Ownership -In a session with nothing to detach from, `Detach` says so on the pane's message -row and does nothing. That falls out of the design rather than being special- -cased: a local shell leaves `push_detach` null on its vtable, and `perform` -reports `NotAttached` for a null method. `Detach` does NOT turn a local session -into a daemon — that is the true inverse of the in-place switch, it needs real -daemonisation, and it is deliberately not this feature. +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. -Bare `--attach` and bare `Attach` mean "the session that is there", because -bare `--detach` names itself by its own pid and nobody can be expected to read -a pid out of `$XDG_RUNTIME_DIR`. With exactly one session listening that is the -one meant; with none or several, `detached_client.resolve` says which case it is -rather than picking one. +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. -## Where the socket lives +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. -`nested.socketDir`: `$XDG_RUNTIME_DIR`, else `~/.local/state/pardes`, created -`0700` by `nested.ensureSocketDir` — never `/tmp`, because this socket carries -keystrokes into a live editor, and a world-writable directory means both that -somebody else can plant a listener at a path you will derive and that a file -they planted cannot be unlinked. `server.socketPath` spells the name -`pardes-detached-.sock` and refuses a name that is empty or holds a `/` -or a NUL, because either would move the address somewhere else. The prefix -differs from `nested.socketPath`'s `pardes-.sock` so that nested.zig's -sweeper, which recognises only an all-digit pid, can never unlink a live -session called `work`. +## Wire and tests -`bind(2)` decides who owns a name, because on a unix socket it is an atomic -exclusive create. `listen` does not unlink first: a name whose socket ANSWERS -is a live session and the bind is allowed to fail, and the only file this -process removes is one `alive` proved dead — which it says only of a connect -that was REFUSED. An unconditional unlink-before-bind is how a second -`--detach=work` used to take the socket away from every frontend attached to -the first. The window `alive` cannot see is stated in its own comment: a -session between its `bind` and its `listen(2)` also answers ECONNREFUSED, it -is two syscalls wide, and the loser of that race loses a NAME rather than a -session. +`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. -Both ends vet, through one predicate — `server.zig` `vetted`, which asks `ours` -three questions of the DIRECTORY and then the same three of the SOCKET: the -right file type, our uid, and nothing granted to group or other. A frontend -that checks only one of the two has checked neither. `Client.open` asks -`access(F_OK)` before it vets, so a mistyped session name reports `NoSession` -rather than `NotPrivate` — the latter promises that the socket IS there and is -reachable by somebody else, which is a different sentence to say to a human. - -## What the daemon owns - -**Everything with an operating system under it.** The eight effects that were -once routed to one frontend to perform — `push_spawn`, `push_pty_write`, -`push_pty_resize`, `push_write_file`, `push_write_dump`, `push_watch_file`, -`push_watch_theme`, `push_dump_themes` — are performed by the daemon itself, -through `src/host_io.zig` and `src/file_watch.zig`. - -This is the whole design and it is worth stating why. A unix socket means the -core and its frontends are on the same machine, so there is no question of whose -disk or whose process table is meant. Given that, the pane shells belong to the -long-lived process: a shell forked by a frontend dies with that frontend, and -then the session has a pane with no shell in it — which contradicts the one -promise a detached session makes. It also meant `tty.zig` had to reimplement the -shell's own Host, so `spawn`, `writeFile`, `watchFile` and `dumpThemes` each -existed twice in that file, and a second frontend would have been a third copy. - -A consequence worth knowing: the daemon forks its pane shells whether or not -anybody is attached. Start a session, attach nothing, and `ps --ppid ` -already shows a shell. - -The daemon is **single-threaded**. Its pane pty masters and its one watch -descriptor live in the same `poll(2)` that accepts frontends — -`poll_slots = 1 + max_clients + MAX_PANES + 1` = 50 descriptors — so there is no -thread per pane and no thread per client. Pty output enters the core as -`core.update(.{ .output = ... })` out of a stack buffer, so a chunk is never -duplicated. Dead shells are reaped with `waitpid(-1, WNOHANG)`. - -Two capabilities exist only because the ptys are here: `pull_tty_taken` can -answer whether a pane's shell has a full-screen program in it (so an `Exec` is -typed into vim instead of at the shell), and `push_poll_frame` reports each -pane's live cwd to its tag. A frontend could do neither — it had the pid but no -core to report to. - -## What a frontend does - -Input and screen, and exactly three effects: - -| message | routing | what the frontend does | -|---|---|---| -| `set_clipboard` | broadcast | put it on **this** display's clipboard | -| `read_clipboard` | origin, else primary | read this display's clipboard, send it back as an ordinary paste | -| `open_link` | origin, else primary | open it in **this** display's browser | - -Those three survive on the wire because each needs the human's own display and -cannot be done by a process nobody is looking at. Everything else the frontend -receives is a `frame`. It never forks a shell, writes a file, or watches a path. - -`read_clipboard` and `open_link` go to the frontend whose event was applied most -recently, because both answer something a human just did: the paste must come -from the keyboard that asked for it, and a link must open in front of the person -who clicked it. The fallback to primary — the lowest attached slot, i.e. the -oldest surviving attachment — covers an effect no input caused. - -## The wire - -`src/detached/wire.zig`, protocol `version` 1, checked on connect and refused -loudly, because `zig build` replaces the binary under a running session and a -frontend decoding another version's frame layout would paint garbage and blame -the terminal. Every message is `tag:u8, len:u32le, payload[len]` — `header_len` -is 5 — and `max_payload` is 16 MiB, a bound derived from the two messages that -set it: a full frame of the largest grid the protocol admits (`max_cols` 512 by -`max_rows` 128, worst case one run per cell, about 1.6 MiB) and one paste, -which the tty frontend already caps at 4 MiB. - -**Core to frontend: eight messages** (`ServerTag`), and the split between the -two ranges is the design rather than housekeeping: - - # 0x01..0x0f — SESSION CONTROL. Not effects; the session talking about - # itself and about this connection's membership of it. - welcome = 0x01 refuse = 0x02 frame = 0x03 quit = 0x04 detach = 0x05 - - # 0x10.. — one `push_` method each, in Host.VTable's own order. Only the - # three that need THIS human's display are here; see "What the daemon owns". - set_clipboard = 0x10 read_clipboard = 0x11 open_link = 0x12 - -**Frontend to core: eleven** (`ClientTag`), and the whole set is a handshake, a -goodbye, and what a keyboard, a mouse, a trackpad or a window manager produces: - - hello = 0x01 bye = 0x02 - - key = 0x10 mouse = 0x11 resize = 0x12 paste = 0x18 - command = 0x19 pdf_scroll = 0x1a pinch = 0x1b - touch_scroll = 0x1c pointer_leave = 0x1d - -Six numbers are missing from that input run — `0x13..0x17` and `0x1e` — and the -gaps are left rather than tidied away, because renumbering is a change every -deployed frontend feels. They were `output`, `eof`, `lsp_resp`, `pipe_resp`, -`file_changed` and `tick`: the machine-local host's own reports, which stopped -being a frontend's business when the daemon took the disk and the process -table. Deleting them was not housekeeping either. `server.zig` `apply` routes -any decoded non-resize event straight into `core.update`, so while those tags -decoded an attached peer could forge a pane's output, forge an `eof` for a -shell that was still running — and unlike the daemon's own `paneEof` that path -never called `closePty`, so the master stayed open and the shell was orphaned -for the life of the session — or replace a pane's text with bytes the next -`Save` would write to disk. - -The format is deliberately **architecture-neutral**: explicit little-endian -widths, no `usize` anywhere, no struct blits, a length prefix on every slice, -tags chosen in this file rather than taken from `@intFromEnum` of a core type, -floats as their binary32 bit pattern inside an explicit `u32`, and a bool that -is 0 or 1 and a decode error otherwise. A pointer-sized field would be 4 bytes -on a 32-bit frontend and 8 here, so none is sent. It is **build-neutral** for -the same reason: `Event.resize.cell_pixels` exists only in a build with native -PDF placement compiled in, so it is always on the wire and dropped on arrival -by a build with nowhere to put it. Today both ends are x86-64 Linux; the -neutrality is what makes a riscv32 end possible later without a format change. - -Frames are diffs against what a client actually has. A client with bytes still -owed to the kernel is **skipped** for this frame and its mirror is left alone, -so a slow frontend sees fewer, larger frames rather than a growing queue. - -## The in-place switch - -`Attach` is a core-side word that emits `Effect.attach{pane, name}`; the frontend -drains it with `Pardes.takeAttach()` beside the existing `takeRestore()`. No host -method performs it, because attaching replaces the core the call is running -inside — so a shell that never polls simply cannot attach, and `host.zig` needed -no change. - -**Connect first, swap second.** The frontend opens the client and, only on a -handshake that actually succeeded, tears the local session down — reaping pane -shells, closing watches, unmounting the control filesystem, deinitialising the -core — and enters the thin attached loop. On any failure it changes *nothing*: -the message row on the pane that ran the word says why, and the editor carries -on locally with every pane and its undo history intact. A failed `Attach` is a -no-op, never a half-dead editor. - -## Fairness, and why no client can stall another - -* Every descriptor is non-blocking; one `poll(2)` per pump covers all of them. -* Frames are not queued (above), so coalescing costs no byte surgery. -* A client's out-queue holds control messages and is capped at 1 MiB - (`out_backlog`). The cap is checked *before* an append, so an oversized - message still goes out whole and what gets refused is a client that has - stopped draining: it is closed, its peers untouched, and it may reattach and - be sent a full frame. -* The TABLE is accounted too, not just each slot: `session_backlog` bounds every - in-queue and out-queue together, and a drained client hands back anything - above one `read_chunk` (`idle_retain`, 16 KiB). Its value is *derived* — - `2 * wire.max_payload`, 32 MiB — and the derivation is the fix. It used to be - a literal `4 << 20`, which was by coincidence exactly tty.zig's - `max_paste_bytes`; since a client's `in` grows to hold one WHOLE message, a - frontend assembling the very paste `max_payload` is sized for crossed the - table's ceiling *while still receiving it*, and the session closed its only - frontend mid-paste with the diagnostic for a peer that had stopped reading. -* A PANE is not a client, so it cannot be closed to reclaim anything. Its - shell's input is queued behind a POLLOUT on the descriptor already in the set, - bounded by `pty_backlog` (1 MiB), and past that the write is REFUSED and said - out loud on the pane's own message row — dropping input silently loses half a - command line, and killing a shell to reclaim a megabyte destroys work. Before - this, one `write(2)` to a master could park the whole daemon: `sleep 3600` - plus a paste larger than the pty's 4 KiB input buffer meant no frame to any - frontend, fifteen other masters unread, no `accept`, no watch drain. -* `max_clients` (32) is a **refusal**, not a queue. The listener is always - accepted from even when the table is full, so the refusal can be spoken — a - level-triggered `poll` on a backlog nobody accepts returns ready forever and - spins a core. A connection that never says `hello` also loses its slot, after - `greet_deadline_default_ms` (5 s), the one number both ends of this transport - time the handshake against; a slot held by silence is the same denial as a - queue arrived at from the other end. -* A peer speaking another protocol version gets `refuse(version)` and the - session survives. So does a peer that sends a byte the decoder does not know. - -## Limits - -Stated rather than papered over: - -* **The screen is shared, at the smallest common grid.** Two frontends of - different sizes converge on the smaller; the larger window letterboxes. Same - semantics as tmux. -* **A frontend asks for at most `max_cols` x `max_rows`** (512x128, above). - A window bigger than that — a 4K display at a small font is already past 128 - rows — attaches at 512x128 and letterboxes the rest, exactly as it does - beside a smaller frontend. `client.zig` clamps the hello and every resize, - because the geometry itself does not fit the wire: an unclamped one was - refused by the session's decoder as `BadValue`, and that refusal reaches the - frontend as a bare hangup with no reason attached. -* **No LSP and no selection pipe** in a detached session. The daemon implements - **seventeen** of `Host.VTable`'s **twenty-one** methods — fewer than the tty - and SDL shells, which install nineteen each, everything but - `pull_gpio_toggle` and `push_detach` — and it is the only host that - implements `push_detach` at all. The four it leaves null divide cleanly. - Two are real losses: `pull_lsp` and `pull_pipe` want a worker pool this - deliberately single-threaded loop has not got. They fall back - to the core's in-process defaults rather than failing, so the features are - quiet rather than broken. The other two are not losses at all: there is no - moment "after the frame is on screen" for a process with no screen - (`push_post_present`), and no pads to toggle on a PC (`pull_gpio_toggle`). -* **`--fs` is inert rather than refused.** `main.zig` hands `opts.fs` to - `server.run`, which imports no `fs_service` and mounts nothing, and `spawn` - passes a null mount directory to `host_io.forkShell`. So - `pardes --detach --fs` gives a session with no control filesystem, no - `PARDES_FS` in its pane shells, and no diagnostic saying so. -* **Frontends are the terminal and the SDL window only.** `main.zig` dispatches - `--attach` to `tty.run` and `gui.run`, and refuses any other platform with - `pardes: --attach needs the tty or gui shell`. The other three shells — - `-Dplatform=web`, `-Dplatform=macos`, `-Dplatform=esp32p4` — are entered by - their own hosts and never link that file at all. -* **Linux.** The code carries darwin branches (`sun_path` is 104 there rather - than 108, and SIGPIPE is per-socket rather than per-write), but only Linux is - built and tested. -* A pane's shell is the daemon's child, so `Kill` in a frontend ends a shell for - everybody attached. That is what one shared session means. -* **An attached window does not get the session's font.** `Font ` is a - core setting raised through `takeFontRequest`, and an attached SDL frontend - has no core to raise it — so a window that would load `MartianMono-NrRg` - locally keeps its embedded Adwaita Mono when attached, and its cell metrics - differ from the same window run locally. The choice belongs to the session but - the fonts belong to the display, so closing this means putting the request on - the wire; nothing does today. -* **An attached pane tagline is drawn in the body face**, not the condensed - tagline face. The compacted band is positioned from the pane rectangle that - produced it, and the wire carries cells rather than rectangles: in a - multi-column layout two panes' tag runs touch, so the origin cannot be - recovered from the frame alone without bleeding one column's band into its - neighbour. Painting and hit-testing therefore agree on the body grid, which is - what keeps a click landing on the glyph it was aimed at; the visible cost is - one row per pane of looser tracking. - -## Tests - -`zig build unit-test` runs twelve tests in `src/detached/client.zig`. Eleven -drive a real core over a real socket: a frontend is greeted -and sent a screen; input comes back as a diff; two frontends share one screen -at the smallest common grid; a frontend that dies takes nothing with it; a -wrong-version peer is refused, loudly; a peer that sends an undefined tag byte -loses its slot and not the session; the session outlives every frontend, keeps -the grid where the last one left it, and lets the next one take it over; the -three surviving effects route as documented above — `set_clipboard` to both -frontends, `read_clipboard` and `open_link` to the origin, and to the primary -once the origin is gone; the client table refuses rather than queues; and a -frontend that stops reading is dropped; and a window bigger than the protocol's -grid attaches at `max_cols` x `max_rows` rather than being refused, which is -also the one test that drives a full frame of the largest grid the wire carries. - -The twelfth builds no harness, opens no socket and touches no core, and that is -the point: it pins the DESIGN rather than the behaviour, and `wire.zig` has its -mirror, one test per direction. "A frontend is never asked to fork, write, or -watch" walks -`wire.ServerMsg`'s fields BY NAME — so re-adding `spawn` fails with the name in -the failure — and then every one of the 256 tag bytes, so a session built -before this change cannot talk a frontend into forking either. "A session is -never told to do a frontend's remembering" is the same argument pointed at the -other end, and it is what keeps the six deleted `ClientTag` numbers -undecodable. +`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. diff --git a/docs/fs.md b/docs/fs.md new file mode 100644 index 00000000..8805ffde --- /dev/null +++ b/docs/fs.md @@ -0,0 +1,89 @@ +# Filesystem + +Every native session serves 9P2000 on a Unix socket. Pane shells receive +`PARDES_9P` (socket path) and `PARDES_PANE` (pane serial). The socket is +`$XDG_RUNTIME_DIR/pardes-9p-.sock`, or lives under +`~/.local/state/pardes` when XDG_RUNTIME_DIR is unset. Detached sessions use +their session name; `--9p=` overrides it. + +A `pardes ` launched from a pane forwards Look to that pane over 9P. +`--nested` opens a separate editor and disables forwarding from its pane shells; +its 9P service stays available. + +Look resolves the OS filesystem first, then the editor's virtual filesystem. +Explicit paths bypass that search: + +| Editor path | Meaning | 9P server path | +|---|---|---| +| `/n/os/proc/self` | OS filesystem | `/os/proc/self` | +| `/n/self/pane/2/body` | pane 2's text | `/self/pane/2/body` | +| `/virtual/src/pardes.zig` | source embedded in this build | `/self/src/pardes.zig` | +| `/n/peer/self/pane/2/body` | another session's text | peer's `/self/pane/2/body` | + +`--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`. 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. + +`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; +`/self/listeners` reports all active dial addresses. + +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`. +The four application connection slots are shared across transports; +OpenSSL's internal buffers are separate, dynamically allocated memory. + +[Plan9port's client](https://9fans.github.io/plan9port/man/man1/9p.html) can +drive Unix or TCP without a kernel mount: + +```sh +9p -n -a "unix!$PARDES_9P" read self/index +9p -n -a 'tcp!127.0.0.1!5640' read self/index +``` + +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 mount root contains `os` and `self`. Kernel mounts +are not part of the test suite. Neither 9P2000.u nor 9P2000.L is implemented. +Existing Plan9port/v9fs clients need a userspace bridge for QUIC. + +The server root contains `os` and `self`. Under `self`, `index` lists panes, +`new/ctl` creates a pane and returns its serial, and `pane/` contains +`body`, `tag`, `ctl`, `addr`, `data`, `event`, and selection files. Terminal +panes additionally have `pty/{ctl,status,data}`. + +`EffectCode ` lists the current backend's embedded implementation +files under `/virtual`; Look opens their full source. + +`self/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, including across multiple reads. The independent client +can inspect it directly with `Client(socket_path).screen()`. + +A terminal `body` freezes its history on the first read of each open handle; +later reads use the same bytes while output continues. Close and reopen for +newer history, or read `pty/data` for the live stream. Screen and terminal-body +snapshots share 32 handle slots, released on close or disconnect. + +Writing `body` appends; opening it with truncation replaces its contents. +`addr` selects a range and `data` replaces that range. `ctl` accepts `name`, +`look `, `get`, `put`, `del`, and `delete`. Look resolves from that pane +without editing its body or tag. Holding `event` open redirects the pane's Look +and Exec clicks to that client; writing a record back performs the action. + +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; protocol create, remove, rename, and other metadata +changes are refused. Ownership, permissions, and timestamps are synthetic. + +`zig build fs-test` drives real sessions using the independent Python client +in `test/ninep.py`. `zig build 9p-test` checks the freestanding wire protocol; +`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. diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 048d8397..dcd5b7cb 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -13,21 +13,20 @@ single key — it is the anchor-only divergence every insert-mode edit shares and is described in the Files list at the bottom instead. Code map, by SYMBOL — line numbers rot, names do not. Body-normal key -RECOGNITION is `src/normal_input.zig`: a pure state machine over `Role` (one +RECOGNITION is `modal.Normal` in `src/modal.zig`: a state machine over `Role` (one per bound command, matched against `src/config.zig`'s chord lists by `Pardes.normalInput`), a `Prefix`, a `MatchSub` and a count, emitting a semantic `Action` that both the text and PDF adapters consume. It knows nothing about panes or text. `src/pardes.zig` then EXECUTES: `Key`, `handleKey` (intercept order is load-bearing, see the comment there), -`handleNormal` (marshals `Pane`'s compact fields in and out of -`normal_input.State` through `paneNormalState` / `putPaneNormalState`), +`handleNormal` (parses directly into `Pane.normal`), `executeNormalAction`, `setPaneRange` (helix range → pane state), `enterInsert`, `handleInsert` / `insertKey`, `insertTab`, `normalDelete`, -`normalYank`, `normalPaste`, `normalChange`, `replaySels`, `multiOnce`. Undo -and redo are per pane kind, in `file_pane` and `term_pane`. Pure text math: +`normalYank`, `pasteText`, `normalChange`, `replaySels`, `multiOnce`. Undo +and redo live in `panes.File` and `panes.Terminal`. Pure text math: `src/modal.zig` (the `hx*` family is the helix-semantics layer: gap offsets + ranges over the flat text). Differential harness: `test/hxdiff.zig` + -`test/hxcases/*.jsonl` + `test/hxcases/regen.sh` — see "Differential testing" +`test/hxcases/*.jsonl` — see "Differential testing" at the bottom. ## Global semantic divergences (read first) @@ -147,13 +146,10 @@ Status: `todo` → set to `done (phase N)` as rows land. All rows verified against keymap.md. Edit ops on terminal panes obey the immutable-output rule (typed runs only) — same as `d`/`c` today. -Phase 2's recognition state is now `normal_input.State`: a count (accumulator, +`Pane.normal` holds `modal.Normal.State`: a count (accumulator, capped 0xffff), a `Prefix` (`g` `z` `m` `f` `F` `t` `T` `r` `]` `[`), a `MatchSub` (m-mode's `i`/`a`/`s`/`r`/`d`) and one `held_char` for `mr`'s -``. `Pane` keeps the same four fields it always did — `count`, -`pending`, `pending2`, `pending_ch` — but purely as compact per-pane storage, -marshalled in and out by `paneNormalState` / `putPaneNormalState`; the -comments on them say so. `find_op`/`find_ch` (the `Alt-.` repeat target) stay +``. `find_op`/`find_ch` (the `Alt-.` repeat target) stay on `Pane`, because the parser emits `repeat_find` without remembering what was found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, `paragraphFwd`/`paragraphBwd`/`paragraphRange`, textobject/surround ranges, @@ -379,7 +375,7 @@ Files (all in `test/hxcases/`): immutable) or doc-marked terminal no-ops. tty case texts keep motions inside the content (the tty motion surface trims trailing blank rows, so ge/G-to-last-line style assertions stay off tty). -- `goldens.jsonl` — checked-in helix results, regenerated by `regen.sh` +- `goldens.jsonl` — checked-in helix results, regenerated by `zig build hxdiff-update` (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. @@ -417,7 +413,11 @@ Run it: # "editing a shell pane behaves like editing a # file" cannot drift. The case's own "pane" field # is ignored; exemptions in parity-waivers.jsonl - sh test/hxcases/regen.sh # regenerate goldens + zig build hxdiff-live # compare a fresh reference without changing goldens + zig build hxdiff-update # update goldens only after the live comparison passes + +Set `-Dhelix-harness=/path/to/hx-harness` or `HX_HARNESS`; otherwise these +steps look for `hx-harness` on PATH. The helix half (`hx-harness`) lives on the `pardes-harness` branch: a full headless `Application` with LSP/tree-sitter/auto-pairs/word- diff --git a/docs/lsp.md b/docs/lsp.md index 4b2e5ba2..82586344 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -1,551 +1,72 @@ -# Language intelligence in pardes - -Three things landed together, and only the first two are permanent: - -1. **An async execution model.** The core stays a state machine; slow work goes - to a worker and comes back as an event. -2. **A helix-exact keymap** for every LSP command. -3. **A seam** (`src/lsp/lsp.zig`) with exactly one function behind it, so competing - backends can be swapped, measured, and thrown away. - -## The async model - -There was none before this: every effect the core emitted was fire-and-forget -(`spawn`, `write`, `save_file`) or instantaneous. A language query is the first -thing pardes asks for that *answers later*, so it needed a request/response -shape — and got the smallest one that works. - -``` -core shell worker - | Effect .lsp{id,kind, | | - | pane,offset,arg} | | - |-------------------------->| | - | | snapshot path + content | - | |--------------------------->| - | | | lsp.query(...) - | | Event .lsp_resp{id,rows} | - |<--------------------------|<---------------------------| - | lspResponse -> atomic edit, jump, or results buffer | -``` - -The shell already ran this exact pattern for pty readers, so the async part is -about thirty lines per shell: `src/tty/tty.zig` uses `io.concurrent` + the -vaxis loop queue, `src/gui/gui.zig` uses a detached thread (`lspThread`) + the -mutex queue it already had. A FREESTANDING core compiles in no backend at all -(`zls_backend = !freestanding_core` in `build.zig`), which is both the web -shell and the ESP32-P4 object: there `lsp.supports` is empty, which makes -`lspRequest` return before it emits, so the effect is never even raised. -`web.zig` leaves the host's `pull_lsp` null and exports nothing for a -response; a host that links a backend would add both. - -**In a DETACHED session every query answers EMPTY.** -`src/detached/server.zig`'s vtable implements seventeen of `host.zig`'s -twenty-one methods, and `pull_lsp` is one of the four it leaves null — a -worker pool is precisely what its deliberately single-threaded loop does not -have. A null method is NOT automatically a dropped effect: `perform` decides -that per arm, and the `.lsp` arm's answer is to synthesise one on the spot — -an `lsp_resp` Event with `rows = ""`, fed straight back into `update`. `.pipe` -one arm below does the same, yielding -`pipe_resp{ .success = false, .outputs = &.{} }`, so a null `pull_pipe` is a -pipe REPORTED as failed rather than one that hangs. So `gd` jumps nowhere, -`gr` finds no references, `SPC l r` renames nothing (an empty edit list parses -as none) and Tab after a dot offers nothing — all of it indistinguishable from -a backend that found nothing, which is exactly what the seam's "no rows is a -legal answer" rule promises. - -**Tab still indents — still, not always.** The empty response reaches -`lspResponse`, whose `rows.len == 0` prong performs the indent the Tab prong -skipped, but only while the cursor has not moved. That -guard holds on the ordinary path because of `pump`'s order: `pull_wait_input`, -then the queued events, then the effects, then render. The effect Tab emitted -is performed after every keystroke that was ALREADY readable in the same -round, since `pull_wait_input` applies a whole batch and not one event (the -tty shell says so at the head of `waitInput` — "block for one event, then -apply the whole pending batch" — and the daemon's poll loop drains every -readable client `.event` straight into `core.update`). One keystroke per wake -is the normal case and the indent lands in the same frame, before render. In a -BURST where the key after Tab was readable in that same poll round, `cur_col` -has moved by the time the prong runs, the guard fails, and the Tab really is -eaten. The local shells have the same race over a wider window, so this is a -property of the late-indent repair rather than of detaching. - -None of the detached core's four null methods silently drops a reachable -effect. `pull_lsp` and -`pull_pipe` have the fallbacks above; `pull_gpio_toggle` is -`orelse return Error.NoPads` (`board_memory.zig`), which lands on the message -row; `push_post_present` is a `pump` hook fired after presenting, and there is -nothing to notify in a process with no screen. `push_fs_reply` was listed here -too until the daemon began mounting its own `/dev/fuse`; it implements that one -now, and `--fs` works in a detached session. - -Four rules make it safe: - -- **The worker never touches the core.** Path, source, arg and root are copied - into an `LspJob` before it starts — one per shell, in `src/tty/tty.zig` and - `src/gui/gui.zig`. The user keeps typing while a - query is in flight; a borrowed slice would be a use-after-free the length of - one keystroke. -- **One query in flight, identified by a monotonic id.** A second press bumps - the id, which makes the older answer stale. The pending request also records - the pane serial, so a closed-and-reused slot cannot accept its response. -- **Mutating answers are revision-checked.** Rename records the file revision - sent to the worker and applies nothing if the user edited before it answered. -- **No rows is a legal answer.** A backend that cannot answer appends nothing, - which is indistinguishable from a language server still starting up, and the - core does nothing. There is no error path to render. - -## Results are `+Search` rows - -Every backend renders into one format: - -``` -sub/file.zig:LINE:COL text under the asking window's dir -sub/file.zig:LINE:COL-ENDCOL text -/abs/path/elsewhere.zig:LINE:COL text anywhere else -``` - -1-based line and column. The second form carries the answer's -RANGE where the protocol gave one on a single line (a token, a symbol's name), -and a look on it SELECTS that span rather than parking at its first cell — so -`gd` lands on the whole name, and a `gr` reference stepped to with `n`/`N` and -opened with Enter arrives with the reference itself selected. This is the -format `look.zig` already resolves, `runSearch` already produces and `n`/`N` -already walk — so: - -- **one row from a goto** → jump straight there (`lookAt`) -- **several rows** → an output buffer, which `n`/`N` walk: a step SELECTS a row - and Enter opens it - -which means helix's multi-result picker required **no picker code at all**. The -`+Search` buffer *is* the picker. Non-location answers (`hover`, `code_action`, -`format`) open `+Hover`/`+Lsp` instead, which are prose and not places — `n`/`N` -find nothing look-able in a documentation blurb and walk straight past it to the -next pane on the ring. - -Rename is deliberately the one exception to rows as presentation. The backend -emits `@edit START END` records through `lsp.edit()`, using half-open byte -offsets into the exact `Req.source` snapshot it resolved. The core validates -that every range is ordered, non-overlapping and in bounds, checks that the -pane serial and file revision still match, then substitutes the requested name -across all ranges with one allocation and one undo transaction. A malformed, -stale, or empty response changes nothing. The current ZLS backend resolves and -renames references in the **current file only**; it does not claim a workspace -rename. - -**A path UNDER `Req.root` is written relative to it; everything else keeps its -full absolute path** (`lsp.rel`). `Req.root` is the directory of the file the -query was asked about — the window that generated the buffer — and a `gr` over -one file was otherwise the same forty-character prefix repeated down the whole -pane, with the part you came to read pushed off the right edge. The short form -resolves because `Req.root` is also the directory the results buffer is *named* -in (`output_pane.open`), and a look resolves a relative word against the -directory of the pane it was clicked in — which is that buffer. - -Under, never "shorter": a hit outside the tree is **not** walked up to with -`../`. An absolute path resolves from anywhere and says where it is; a `../..` -chain says neither, and stops being true the moment the row is read anywhere but -beside its own buffer. `test/snapshots/lsprelpath.snap` pins both directions end -to end — the enum one level up (absolute row) and one level down (`inner/tint.zig`, -a stripped path that still has a separator in it), each with the `n` step that -selects it and the Enter that opens it, plus a right click. - -This is the rule `look.grep` follows for its own rows, and since the client -landed it is spelled ONCE: look.zig's `grep` calls `lsp.rel` for its `shown` -paths rather than keeping the inline twin this paragraph used to complain -about. - -`completion` is the kind this shape changes the most. Every other editor answers -a dot with a popup of NAMES to insert; a seam that returns locations cannot -insert anything, so this one answers with the candidates' **declarations** — -one row each, in the same `+Search` buffer, steppable with `n`: - -``` -path:LINE:COL-ENDCOL name the candidate's declaration line -a.zig:2:5-13 verdigris verdigris, -``` - -The name comes first because that is the thing you would type — the row answers -"what goes here" before "where does it come from", which is the order the -question was asked in; every other kind here answers a WHERE, and for this one -the location is the evidence rather than the answer. It is not padded into a -column: the location in front of it is already ragged, so there is nothing to -align to. That is a different and arguably better answer to "what goes here": -you get the word AND you can read the definition rather than a list of words. It -is the one location kind that does NOT jump on a single row, because with one -candidate you still want to see the list rather than be teleported into it. - -A results buffer is REFILLED rather than reopened when the same kind is asked -again — the rule `runSearch` always had, and which the language path was -missing. It survived being missing while every query was a deliberate press -(`gr` twice left two identical lists and you closed one); Tab after a dot is an -ordinary typing keystroke, and measured, twenty of them stacked **fifteen** -byte-identical `+Search` panes, crushed the file to one visible line, and then -ran `freeSlot` out so the key was silently eaten for the rest of the session. -Unlike a search the ARGUMENT is not part of the identity: a language query is -asked about a different symbol every time with the same (usually empty) arg, so -the kind is the unit. - -Making it work needed one trick. A completion is asked for exactly when the -line is half-typed, and a half-typed line does not parse: `switch (e) { . }` -loses the whole switch to the parser's error recovery, taking with it every -ancestor an expected-type resolution needs. ZLS answers this with a private -token scanner welded to its `*Server`. `lsp_zls.completionSource` instead makes -the tree PARSE — it splices a placeholder in after the dot, in the six -spellings a half-typed line can need — three shapes, each with and without a -closer still hanging: a switch prong (`_p => {},` / `_p => {}, }`), an -unterminated statement (`_p;` / `_p)`), and a bare identifier (`_p` / `_p }`) -— and keeps the one that both makes -the dot reachable in the tree and leaves the fewest parse errors. Everything -after that is ZLS's ordinary public resolution over an ordinary tree. - -**A Tab the backend cannot answer still indents.** The keystroke has already -diverted by the time "no rows" comes back, so `lspResponse` performs the indent -the Tab prong skipped — on the condition that the cursor has not moved since, -so nobody who kept typing gets four spaces landing behind their hands. Without -that, a dot in a comment, in a string, or on a line nothing can be made of ate -the keystroke outright. With several cursors Tab never diverts at all: a -language query is a per-keystroke action inside a per-selection replay, so -asking would stop the replay dead and collapse the multicursor. - -### What it costs, and what it cannot do - -Per press. **Both timings date from 2026-08-09**, change `lmlltvrx`, and have -not been re-measured; the line count beside the first was refreshed once -afterwards, on 2026-08-12 in change `vwtlskzr`, and `src/pardes.zig` is 16 466 -lines today. The ReleaseFast column is `zig build lspbench`, which is pinned to -ReleaseFast in `build.zig` and always has been — so the Debug column came from -running the installed editor by hand and the repo records no harness for it. A -completion parses the buffer once per placeholder spelling it tries, so the -first row scales with the file: re-run rather than trusting either number. - -| | ReleaseFast | Debug (what `zig build` installs) | -|---|---|---| -| a switch arm in `src/pardes.zig` (14.6k lines) | 8.8 ms | 87 ms | -| `std.` — 91 candidates, each alias-resolved into the stdlib | 26 ms | 204 ms | - -It is a worker thread, so the editor does not block. On the TTY shell the -second press of Tab then joins the first query on the UI thread — -`old.cancel(s.io)` on the one in-flight future, and a backend that ignores -cancellation means waiting out a query the user already abandoned -(`src/tty/tty.zig`, the `lsp` vtable entry, whose own comment says so). The -GUI shell does not join: it spawns another thread per request and lets the -core's monotonic id make the older answer stale, so it pays memory instead of -latency. Pre-existing and shared by every LSP kind — not this feature's to -fix, but it is what a fast double-Tab feels like on a terminal. - -Known limitations, in the order you will meet them: - -- **`@This()` anywhere in a container makes the whole container unresolvable**, - so `var list: std.ArrayList(u8) = .` — the most common decl literal in this - codebase — answers nothing. This is not the completion filter: `hover` and a - plain field access on the same struct return nothing either. It is the case a - user hits first, and it is upstream of everything here. -- **Only the break AT THE CURSOR is repaired.** Zig's error recovery runs - forward, so an unrepaired break earlier in the file swallows the declaration - the cursor is in and the answer is empty. While typing you normally have one - broken spot, which is the case this works for. -- **A dependency module** (`@import("vaxis")`) cannot be typed at all, for the - same reason `gd` on `vaxis.init` finds nothing. -- **`error.`** is not handled — the position context is `.error_access`, which - no branch claims. - - -## The protocol client: every other language - -`src/lsp/lsp_client.zig` is the second backend behind the same seam: a real -LSP client — JSON-RPC 2.0, `Content-Length` frames — speaking to child -processes. Nothing in it knows any single language; `specs` is a table of -(binary, languageId, extensions, root markers), and rust-analyzer, clangd, -gopls, typescript-language-server and pyright are rows in it. The seam asks -each backend `speaks(path) and supports(kind)` in order, so `.zig` stays with -the in-process analyser (cold is warm, no process) and everything else routes -here. `SPC l i` prints both sections; `backend_name` is `zls-inproc+lsp-client`. - -**One server per spec, one reader thread per server, and the reader is not -optional.** A real server TALKS: rust-analyzer streams `$/progress` for the -whole minutes-long index of a big workspace, publishes diagnostics nobody -asked for, and asks its own `workspace/configuration` questions mid-flight. -The reader owns the read side of the socketpair, routes responses to the one -waiting query (a mailbox under the connection's mutex), answers -server-to-client requests so the server never blocks on us, feeds the -diagnostics store, and narrates state changes through the STATUS SINK — a -callback both native shells register at startup and post to their event -queue, so "rust-analyzer: cargo check 88% 955/1083" lands on the same -transient message row a save narrates into (`message.stamp`, verb `lsp`, on -the ACTIVE pane — server state is session news, not a fact about the pane -that asked). Chatty progress is throttled to one post per 150ms per server -and deduplicated; state CHANGES (starting, ready, exited, errors) always -land, and repeating the row already shown never does — which is also what -makes the settled state deterministic for the snapshot goldens. - -Nothing may wedge the editor, and nothing healthy may be killed for being -busy: - -- every write and every mailbox wait is deadline-bounded (8s handshake, 4s - request); a query the server does not answer in time returns no rows and - sends `$/cancelRequest`; -- three CONSECUTIVE timeouts mean wedged and force a restart — but only - while the server is idle. One with active `$/progress` (rust-analyzer - mid-`cargo check` over a thousand crates) is demonstrably alive, already - narrating its own excuse on the message row, and killing it would throw - the index away right before it pays off. This rule exists because the - first run against a thousand-crate workspace did exactly that; -- a failed spawn or handshake is NOT a session disable: it backs off - exponentially (10s doubling to 2min, reset by the next success), because - the failure that taught this was a rustup shim deciding to download the - project's whole pinned toolchain before launching the real server. Only a - missing binary disables a spec, once, with a message saying which env var - overrides it; -- a server that dies is reaped by whoever saw it die (the reader on EOF, - `shutdownIf` on a transport error), the fd is closed by the READER ALONE — - `shutdown(2)` first, so a polled fd number is never recycled under a - thread still watching it — and the next query respawns, generation-checked - so a stale worker can neither adopt nor kill its successor's server. - -`PARDES_LSP_RS` / `_C` / `_GO` / `_TS` / `_PY` override each spec's binary -(a path or a PATH name); the empty string disables the spec. The snapshot -harness pins `_RS` to `test/lspmock.zig`'s deterministic mock and empties -the rest, so `test/snapshots/lsp-client.snap` (gd across files, gr spans, -n/Enter) and `lsp-client-edit.snap` (format apply, rename apply, one-step -undo for each) drive the REAL client — spawn, handshake, reader, narration — -against answers a golden can quote. `zig build lspprobe -- gd :` -is the same seam from the command line, for pointing at any real workspace; -comma-separated kinds share one server so a big index is paid for once. - -The root is helix's `find_root` rule: walking up from the file, the TOP-MOST -directory holding one of the spec's markers wins (a cargo workspace's root -`Cargo.toml` beats the member crate's), the closest `.git` is the fallback, -the asking directory the last resort. A second project in the same session -becomes a workspace FOLDER when the server advertises support. Position -encoding is negotiated to utf-8 and the server's ANSWER is believed; the -utf-16 conversion is implemented in both directions for servers that refuse. -Diagnostics PULL (`textDocument/diagnostic`, LSP 3.17) is preferred when the -server advertises it — rust-analyzer does — and the push store fed by the -reader answers otherwise, `]d` stepping either for free. - -### Mutating answers really mutate now - -The seam grew a second record form beside rename's `@edit`: `@put START END -TEXT` carries a per-range replacement, percent-encoded onto the one line a -record is allowed to be (`lsp.put`). The core decodes, validates (ordered, -non-overlapping, in bounds, revision unchanged) and applies ALL records as -one undo transaction, then says so on the message row ("formatted 1 -range(s)", "renamed 2 range(s)"). So: - -- `=` FORMATS, like helix — through the client it applies the server's - TextEdits; through ZLS it applies one span covering everything `zig fmt` - would change. The two non-edit answers stay prose in `+Lsp`: a file that - does not parse, and (client-side) a server with no formatter. -- `SPC l r` through the client applies a WorkspaceEdit that stays inside the - asked-about file. One that spans OTHER files (a real workspace rename) - arrives as location rows instead and opens as a PREVIEW list in the same - buffer `gr` fills — applying a fraction of a workspace rename silently - would be worse than either. The ZLS backend still resolves and renames - current-file references via `@edit`, exactly as before. - -### Four kinds helix does not have - -The hierarchy kinds are two-step in the protocol (prepare at the cursor, -then follow the item), are gated on the server capability so an old server -costs zero round trips, and their answers are LOCATIONS — the one thing this -seam renders for free. helix has no binding for any of the four (checked -against helix-term/src/keymap/default.rs). - -| keys | kind | what the rows are | -|---|---|---| -| `SPC l c` | incoming_calls | one row per CALL SITE, under the caller's name | -| `SPC l C` | outgoing_calls | the callees' declarations | -| `SPC l t` | supertypes | the types this one extends/implements | -| `SPC l T` | subtypes | the types that extend/implement this one | - -All four behave like `gr`: a list to walk with `n`/`N`, and a lone answer is -a jump. - -## Which ZLS, and which stdlib - -Both are decided at build time, and `SPC l i` prints both. - -`build.zig.zon` pins ZLS to a COMMIT rather than a tag — -`git+https://github.com/zigtools/zls#3e0d082084be43e36865136a138c1fe2023b33ca`, -on the 0.16.x branch — because master requires Zig 0.17-dev and no tagged -release both builds on 0.16 and exports the internals this backend calls. -`build.zig` spells the same commit a second time, as the top-level -`const zls_version = "0.16.1-dev+3e0d0820"`, and hands it to the ZLS package's -own `-Dversion-string` and to `pardes_config.zls_version`. The duplication is -unavoidable rather than sloppy — the semver half (`0.16.1-dev`) exists nowhere -in the manifest — and it is load-bearing, because ZLS's build otherwise -derives that string from `git describe`, which has nothing to read in a -fetched package with no `.git`. The two are made to AGREE BY CONSTRUCTION: a -`comptime` block right below the constant takes the short hash after the `+`, -takes the pinned commit after the `#` in `zon.dependencies.zls.url`, and -`@compileError`s unless the first is a prefix of the second. A `.zon` bump -that forgets `build.zig` is therefore a build error, not a `SPC l i` naming a -build nobody linked. - -`std` is the harder half. ZLS resolves `@import("std")` through `zig_lib_dir` -and through nothing else, and this backend sets `zig_exe_path = null` on -purpose — asking the `zig` binary is the subprocess the whole design exists to -avoid. So `build.zig` bakes `b.graph.zig_lib_directory.path` into -`pardes_config.zig_lib_dir`: the exact stdlib pardes itself was compiled -against, which is what makes `gd` on `std.mem.count` land in the real -`mem.zig`. `zigLibPath()` (`src/lsp/lsp_zls.zig`) reads `ZIG_LIB_DIR` from the -environment FIRST and falls back to the baked path, so a user who moved the -toolchain can point the backend at it without rebuilding. With no lib dir at -all every `std` symbol is a silent miss — which is why `SPC l i` reports -whether the directory OPENS rather than only which one was compiled in. - -That same `zig_exe_path = null` is the dependency-module limitation above. -ZLS resolves a relative `.zig` path from the filesystem and `std` from the lib -dir, but any other import name — every dependency in `build.zig.zon` — it can -only answer by running `zig build --build-runner` to discover the module -graph. With no zig binary that branch returns nothing, so `@import("vaxis")` -is a silent miss by construction rather than by omission. - -## The keymap is helix's, exactly - -Verified against `helix-term/src/keymap/default.rs`, not from memory. - -| keys | command | notes | -|---|---|---| -| `gd` | definition | jumps on a single result, lists on several | -| `gD` | declaration | | -| `gy` | type definition | | -| `gi` | implementation | | -| `gr` | references | | -| `SPC l k` | hover | opens `+Hover` | -| `SPC l r` | rename | tag input; applies same-file edits in one undo step, PREVIEWS a multi-file WorkspaceEdit as rows | -| `SPC l a` | code action | | -| `SPC l h` | select references | | -| `SPC l s` / `SPC l S` | document / workspace symbols | `S` takes a query | -| `SPC l d` / `SPC l D` | document / workspace diagnostics | | -| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent | -| `]D` / `[D` | last / first diagnostic | | -| `=` | format | applies the formatter's edits in one undo step | -| `Ctrl`+left-click | definition | the mouse spelling of `gd` | -| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined | -| `SPC l c` / `SPC l C` | incoming / outgoing calls | beyond helix — see the client section | -| `SPC l t` / `SPC l T` | supertypes / subtypes | beyond helix — see the client section | - -Tab is the one key here that is not helix's and not a goto. helix's `Tab` -completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search` -buffer and inserts nothing, because that is what a seam returning locations can -honestly do — see below. It only diverts where an answer is possible: on a -terminal, in an output buffer, or in a file the backend does not speak -(`lsp.speaks`, which the core asks and the backend answers), Tab indents -exactly as it always did. A Tab that silently does nothing would be worse than -not having the feature. Nothing about the mode changes either — the pane is -still in insert, so typing goes on and walking the answer with `n` means -pressing `Esc` first, the same as for every other results buffer. - -Ctrl-click rides the ordinary left-click drag rather than firing on the press: -a click does not place the modal cursor until RELEASE, so a query asked at -press time would answer about wherever the cursor previously sat. A ctrl-DRAG -still selects, and still asks about where it started. - -**The gotos are helix's exactly; the leader commands are helix's letters under -an `l` prefix.** `g` and `[`/`]` had no conflicts — `gd/gD/gy/gi/gr` and -`]d/[d` were all free, so they stay where helix puts them. The bare `` -letters were NOT free, and an earlier pass took them anyway, displacing `SPC d` -(Del), `SPC k` (Kill), `SPC s?` (Dump/Restore) and `SPC h t` (Tutor). That is -the wrong trade: those are pardes's most-pressed keys and predate the language -work, whereas an LSP command is something you reach for deliberately and can -afford one keystroke more. So every one of them keeps helix's own letter and -gains the prefix — `k` becomes `SPC l k`, `d` becomes `SPC l d` -— and nothing pardes had moved at all. - -Two more live in the same group because they belong to it, not to helix (the -four hierarchy kinds above are also pardes's own — helix has no spelling for -them): - -| keys | builtin | what it shows | -|---|---|---| -| `SPC l i` | `Lspinfo` | BOTH backends: which ZLS and which stdlib (and whether it opens); every protocol server's state, root, encoding and capabilities; and each side's last 24 queries with timings, row counts and **the errors `query` swallowed** | -| `SPC l w` | `Lspwhy` | why the definition query at the cursor answers what it does — narrated by whichever backend the file routes to | - -These exist because of the seam's own contract: a backend never fails loudly, -which is right for an editor — a thrown analyser must not take the process with -it — but it makes a broken backend and a correct one that found nothing look -identical. `Lspwhy` narrates the REAL resolution path (the position context is -the analyser's own answer, threaded out through a trace) rather than -re-deriving it beside the code, because a debug view that reimplements the -logic is one that can disagree with it. `Lspinfo` answers from ANY pane, -including one with no file, since it is about the backend rather than a -document — which matters precisely when the pane you are in is the problem. - -`K` is **not** hover — in helix it is `keep_selections`. It was checked; do not -"fix" it. - -## Writing a backend - -`src/lsp/lsp.zig` is the seam, and since the protocol client landed it holds a -LIST of backends, asked in order: the first one that `speaks` the file's -language and claims the kind in `supports` answers. An implementation supplies -three things and touches nothing else (`backend_name` is the SEAM's, not -yours — one literal in `lsp.zig` naming the compiled-in combination; a backend -with unsolicited news to deliver may additionally accept the status sink, as -`setStatusSink` shows): - -```zig -pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void -pub fn speaks(path: []const u8) bool -pub const supports: std.EnumSet(Kind) -pub const backend_name = "..." -``` - -`supports` says what a backend can do; `speaks` says what it can do it TO, and -exists for the one key that must not be eaten when the answer is no — see the -Tab note above. - -`query` runs on a worker thread with no access to the core — everything it may -read is in `req` (`path`, `source` (NUL-terminated), `offset`, `arg`, `root`). -`out` is a plain `std.Io.Writer`: the shell owns the buffer behind it (an -`Io.Writer.Allocating`), so a backend never allocates the result, never frees -it, and cannot get the allocator wrong. `arena` is freed wholesale on return; -`gpa` is for a backend's own scratch. Use `lsp.row()` to emit a location, -`lsp.spanRow()` for one that carries the RANGE it matched (the -`path:LINE:COL-ENDCOL` form above), `lsp.rel()` to spell a path against -`req.root`, `lsp.lineCol()` to convert an offset, and `lsp.edit()` for each -half-open range of a rename response. Location -rows are byte-identical across backends; rename ranges are consumed by the core -and never rendered. `rel` allocates nothing — it returns a slice of what you -hand it. - -## How the implementations are judged - -`zig build lspbench` — same harness, same corpus, same 22 probes, every backend. -The corpus is pardes's own `src/`, plus `test/lspfixture/`: five of the probes -point at fixtures rather than at real source. Three of them do because on -clean, already formatted code the correct answer to `diagnostics`, -`workspace_diagnostics` and `format` is nothing, and that is indistinguishable -from a backend that has neither — `broken.zig` carries an unused local and a -misformatted fn, so all three have real work. The other two are the half-typed -dot, whose two shapes are `dotcomplete.zig` (a switch prong that parses -everywhere but at the dot) and `dothalf.zig` (a line also missing its -terminator). The 22 probes cover 15 of the 17 `lsp.Kind`s -— `definition` three times, `document_symbols` twice, `completion` five times, -and the two introspection kinds (`status`, `explain`) not at all. - -- **Feature completeness.** Which kinds return rows, and whether the rows - contain what they should. The harness trusts *results*, not the `supports` - flag: a kind claimed but returning nothing is reported as `CLAIMED-EMPTY`, - and a kind that answers without claiming is `unclaimed-works`. Correctness - is a substring the rows must contain, so returning a confident wrong location - scores worse than returning nothing. - -- **Latency.** `cold` (first query, index construction included) and `warm` - (median of 20). They differ by orders of magnitude for an indexing backend - and both matter: cold is what the first keypress costs, warm is what every - one after it costs. -- **Memory.** Peak RSS delta (`VmHWM`), so a backend that frees its index - before returning still pays for having built it. -- **Lines of code.** Not measured by the harness — it is `jj diff --stat` - against the base commit. Less is better, and vendoring a library is not free - but is charged in build time and dependency surface rather than in lines we - maintain. - -The user-visible rename contract is also pinned through the actual TTY, -leader prompt, worker and ZLS backend by `test/snapshots/lsp-rename.snap`: both -resolved occurrences change, a shadowed local does not, and undo/redo treats -the response as one transaction. - -Run `zig build lspbench -- --json` for machine-readable output. +# Language intelligence + +Native hosts, including detached sessions, run language queries on workers. +Zig uses the linked ZLS analyser; other languages use the protocol client in +`src/lsp/lsp_client.zig`. Web and board builds have no language backend. + +`Lspinfo` (`SPC l i`) reports backend versions, server state, capabilities, +recent timings and errors. `Lspwhy` (`SPC l w`) traces resolution at the cursor. + +## Commands + +| Keys | Action | +|---|---| +| `gd`, `gD`, `gy`, `gi`, `gr` | Definition, declaration, type, implementation, references | +| `SPC l k` | Hover | +| `SPC l r` | Rename | +| `SPC l a` | Code action | +| `SPC l h` | Select references | +| `SPC l s`, `SPC l S` | Document and workspace symbols | +| `SPC l d`, `SPC l D` | Document and workspace diagnostics | +| `]d`, `[d`, `]D`, `[D` | Next, previous, last and first diagnostic | +| `=` | Format | +| Ctrl-click | Definition | +| Insert-mode Tab after `.` | Candidate declarations | +| `SPC l c`, `SPC l C` | Incoming and outgoing calls | +| `SPC l t`, `SPC l T` | Supertypes and subtypes | + +A single goto result jumps directly; several results open a reusable search +buffer. `n`/`N` select result rows and Enter opens them. Hover opens prose. +Locations use one-based `path:line:column` or `path:line:column-endcolumn`. +Paths below the requesting pane's directory are relative to that directory; +other paths stay absolute. + +Completion lists declarations and inserts nothing. An unanswered Tab indents +only if the cursor has not moved since the request. Multicursor Tab indents +without starting a query. + +Formatting and same-file rename apply as one undo transaction. A workspace +rename spanning other files opens a preview; it does not partially apply the +rename. The linked Zig analyser resolves current-file references and relative +imports, but does not run the build runner to discover dependency modules. + +## Configuration and testing + +`PARDES_LSP_RS`, `_C`, `_GO`, `_TS` and `_PY` override the server executable +for each language. An empty value disables that server. `ZIG_LIB_DIR` overrides +the stdlib path recorded at build time. `Lspinfo` shows the effective settings. + +The protocol client keeps one child server per configured language, negotiates +position encoding, and handles both push and pull diagnostics. Startup and +request waits have deadlines; failed starts back off before retrying. Worker +status messages reach the host event queue. + +`zig build lspprobe -- gd :` queries the same backend from +the command line. `zig build lspbench` measures it. Snapshot tests use a Zig +mock server with deterministic answers while exercising the real client, +including process startup, framing, edits and undo. `zig build fs-test` also +checks language-worker delivery in TTY and detached sessions over 9P. + +## Ownership + +`host_io.Lsp.Job` owns copies of the source, path, argument and root. Workers +never read the live core. A request ID and pane serial reject obsolete replies; +mutating replies also require the original file revision. Restore cancels +owned work before replacing the core. + +Backends implement `query`, `speaks` and `supports` in `src/lsp/`. The first +backend supporting the file and query answers; status queries visit all of +them. Results are written to the caller's writer. Use `lsp.row` or `spanRow` +for locations, `edit` for rename ranges, and `put` for replacement text. +Edit records use half-open byte offsets into the request's source snapshot. +The core validates the complete response before applying it. diff --git a/docs/macos.md b/docs/macos.md index 0ce29bf9..2d9fb1cc 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -430,59 +430,17 @@ without a Force Touch trackpad and when the user has feedback switched off, so a check here would only be a second place to be wrong — and it would be wrong the moment an external trackpad is plugged in mid-session. -## libproc, twice - -Two features on this backend want to know something about a process that is not -us, and on Linux both answers live in `/proc`. Darwin's equivalent is libproc, -and it answers both. - -**A pane's cwd** (`look.shellCwd`) is `readlink("/proc//cwd")` there and -`proc_pidinfo(PROC_PIDVNODEPATHINFO)` here. The tag shows it and a relative -`Look` resolves against it, so it has to follow the shell rather than stay -where the pane was spawned. - -*When* it is read differs from the other three shells, and deliberately. The -tty, SDL and detached-daemon hosts poll every pane every frame — inline in the -tty loop's frame path (`src/tty/tty.zig:1228-1231`), `pollCwds` in the SDL -shell (`src/gui/gui.zig:4048`) and `pollFrame` in the daemon -(`src/detached/server.zig:889`); here the drain has just finished -saying exactly which shells produced bytes, and nothing else can have moved -one — a `cd` is a command, and a shell that ran a command writes at least its -next prompt. So `refreshCwds` reads only for panes flagged by that tick's -output and an idle session costs no syscalls at all. `test/macos-snapshots/ -cwd.snap` holds the gating to it: the tag must be right after a `cd` and must -survive a tick with nothing in it. - -**A pardes inside a pardes** (`src/nested.zig`) walks the ancestor chain -looking for our own executable, and hands the file over rather than stacking a -second full-screen UI inside a pane. `readlink("/proc//exe")` becomes -`proc_pidpath`, and the `PPid:` line of `/proc//status` becomes -`proc_bsdinfo.pbi_ppid`. That struct is hand-written, which is a thing to get -silently wrong: a field ordering that puts something else where `ppid` should -be still returns a plausible number, so a unit test compares `parentOf(getpid())` -against `getppid()`. - -The socket half needed real portability work rather than a second spelling. -Darwin has no `SOCK_CLOEXEC` and no `accept4`, so the flag is set with an -`fcntl` after the fact — a race only against a fork on another thread, and -every caller is past that (`src/nested.zig:81-93` names all three). -`sun_path` is 104 bytes here against 108 there, so no -buffer in the file spells a number any more; they are all sized from the field -itself, and an address that does not fit is refused rather than truncated into -a path pointing somewhere else. - -Identity gained a third sibling. `bin/pardes`, `bin/pardes-gui` and -`pardes.app/Contents/MacOS/pardes` are three installed frontends of one -program, so `samePardesExecutable` (`src/nested.zig:142`) compares BASENAMES -and ignores the paths entirely — the GUI may be system-wide while the tty -frontend sits in the user's own bin directory. `familyTail` strips a leading -`pardes-gui` or `pardes` and requires what is left to be either empty or a -valid `-os-arch` pair, which is what keeps `pardes-snap` and `pardes-perf` out -of the family; `sameFamily` then accepts two names whose tails agree, or either -of which has no tail at all. The bundle's copy always has none, because -`CFBundleExecutable` is a fixed string: the bundled `pardes-macos-aarch64` is -called `pardes` and nothing in the name records what it was. So `pardes -src/foo.zig` inside the app's own shell opens a pane in the app. +## Shell directories and nested Look + +`host_io.shellCwd` reads a shell's working directory using +`proc_pidinfo(PROC_PIDVNODEPATHINFO)`; Linux uses `/proc//cwd`. +The macOS host refreshes directories for panes that produced output, so idle +frames do not poll every shell. `test/macos-snapshots/cwd.snap` covers the tag +after `cd` and after an idle tick. + +Nested launches use the same inherited 9P address and pane serial as other +native hosts. They do not inspect ancestor processes or executable names. +See [the control filesystem](fs.md) for paths and transports. ## Fonts and zoom @@ -672,8 +630,7 @@ stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs it through a `CIContext` created from the system Metal device. The source is -also a Zig build import, so `EffectCode` can print what the app executes when -the source checkout is absent. +also archived by the Zig build; `EffectCode` links to it without a checkout. The ordinary CoreText/attachment/cursor pass is one function. With any scene bit or panel track it targets a retained, backing-scale bitmap; kernels then @@ -726,8 +683,8 @@ panes move and dissolve together. Scene CRT/ripple/glitch runs once after the panel composition. With no scene bit and no panel track the retained bitmap, Core Image context, -and Metal passes are bypassed. `EffectCode Panel*` embeds this actual Metal -file plus its direct Swift owner, the same files the app executes. +and Metal passes are bypassed. `EffectCode Panel*` links to this Metal +file and its Swift owner in the virtual filesystem. ### Transparent themes @@ -870,7 +827,7 @@ already do (`install_app_bin`, `install_plist`, `install_scene_kernel`, `CIKernel.kernels(withMetalString:)`. The same file is also an anonymous module import named `effect-source-crt.ci.metal`, added by `build.zig`'s per-shell module wiring under `if (shell == .macos)`, which is what lets - `EffectCode` print the exact source the app executes. + `EffectCode` link to the exact source the app executes. Its three entry points are `extern "C" [[stitchable]]`: the runtime compiler looks for stitchable functions and rejects the WHOLE source with "cannot find a valid stitchable Metal function in the source" when there are none, which @@ -981,12 +938,11 @@ prohibited) `NSWindow`, over a real core with real ptys: ```sh zig build macos-e2e -Dplatform=macos # run it zig build macos-e2e -Dplatform=macos -- --update # regenerate the goldens +zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap ``` -The step always hands the harness the whole `test/macos-snapshots` directory, -so naming one script after `--` runs it IN ADDITION to the suite rather than -instead of it. To run a single script, invoke -`zig-out/bin/pardes-macos-e2e test/macos-snapshots/rotate.snap` directly. +With no paths, the harness runs `test/macos-snapshots`. Paths after `--` +select scripts or directories instead. The executable stays in the build cache. `test/macos_e2e.swift` links the same Swift sources the app does, minus `main.swift`, into a second binary — test scaffolding does not ship inside the @@ -1098,7 +1054,7 @@ used for the table and is not folded into the direct-path claim. - **Detached sessions.** `Attach` (`SPC s a`) and `Detach` (`SPC s D`) exist on every hosted platform, this one included, because `Builtin.enabled` is `pardes.hosted`. Neither works here. `Detach` emits `Effect.detach`, this - host fills in no `push_detach`, and `Pardes.perform` therefore reports + host fills in no `detach`, and `Pardes.perform` therefore reports `error.NotAttached` on the pane's message row (`src/pardes.zig:6837`). `Attach` emits `Effect.attach`, which `perform` turns into an `attach_req` the shell is supposed to consume from OUTSIDE `pump` with `takeAttach` — and diff --git a/docs/registry.typ b/docs/registry.typ index 40af056a..4d386f1c 100644 --- a/docs/registry.typ +++ b/docs/registry.typ @@ -153,6 +153,10 @@ #v(0.8em) +*Historical decision record.* Entries retain their original source references +and states. For the current filesystem and transport interface, use +`docs/fs.md`; FUSE and the proof-of-concept examples are no longer supported. + #context { let es = query().map(e => e.value) let order = ("open", "investigating", "blocked", "decided", "deferred", "landed", "rejected") @@ -488,21 +492,21 @@ rejected whole. Buildable, and much cheaper than anyone assumed. But it is a SECOND FIRMWARE IMAGE, not a second role for this one: the editor owns UART0 bidirectionally (`esp32p4.zig:611`, `uart.zig:43-44,122-130`) and JP1 - exposes no second P4 UART (`board_memory.zig:382-383`). The board is either + exposes no second P4 UART (`board9p.Header`). The board is either an editor or a filesystem at any one time. Say that plainly rather than implying both. ] #note("review", "2026-08-27")[ The reframing the draft misses: the board already exposes `Peek`, `Poke`, - `Hexdump` and `Gpio` as acme words (`src/board_memory.zig:306-349,410-472`, - registered `src/builtins.zig:1009,1025,1038`). All four cap at 4,096 bytes + `Hexdump` and `Gpio` as acme words (`src/builtins.zig`, `Board` namespace). + All four cap at 4,096 bytes per command and the cap's stated reason is the 115200 console. So the whole 2³² address space is already reachable — by *typing a word into a tag*, with the answer landing in an output pane. Nothing is machine-readable and nothing is remote. A 9P tree is that same capability with names instead of verbs, and `mem/`, `gpio/pinout` and `prof` are backed by functions that - exist today (`board_memory.readWord:136`, `:368-390`, + exist today (`builtins.Board.readWord`, `builtins.Board.gpio`, `pardes_esp32p4_frame_prof` at `esp32p4.zig:985`, already exported). Four more files need one new C-ABI extern each; four have no implementation at all. That inventory belongs in the note, not a wishlist. @@ -1022,7 +1026,7 @@ Three of those four are achievable. One is not, and `9P-20` says which. `.lsp_resp`, `.pipe_resp` (`src/pardes.zig:6896-6907`). `pull_wait_input` cannot become an event because it is how events arrive. That leaves two: `pull_gpio_toggle`, whose answer is only used to set a message row - (`board_memory.zig:422-428`), so deferring it a frame is invisible; and + (`builtins.Board.gpio`), so deferring it a frame is invisible; and `pull_tty_taken`, which is reached from `takesCommandLine` (`pardes.zig:6465-6469`) at four call sites, two of which loop over all 16 panes — 16 probes per call, collapsing to ONE read if `proc` is a single file diff --git a/docs/web.md b/docs/web.md index 8d3a3d66..3a68f958 100644 --- a/docs/web.md +++ b/docs/web.md @@ -43,8 +43,8 @@ shells, and two comptime capability flags say so once each. It exposes no `PanelCurtain`, `PanelScramble`, `PanelType`) because `capabilities.panel_transitions` is `pardes.hosted`, and no `Crt`/`Ripple`/`Glitch` because `capabilities.scene_shaders` is -`platform == .gui or platform == .macos` (`src/builtins.zig:41-42`; the words -themselves carry those availabilities in `src/runtime_config.zig:155-168`). +`platform == .gui or platform == .macos` (`builtins.capabilities`; the words +themselves carry those availabilities in `config.Runtime.settings`). Applying either faithfully would require a second canvas renderer and give up the DOM renderer's selectable/accessibility contract. Theme fades and the delayed, side-effect-free Look hover remain grid animations and continue to use @@ -57,14 +57,14 @@ zig build web \ -Dplatform=web \ -Dtarget=wasm32-freestanding \ -Dtree-sitter=zig \ - -Ddump=test/web-snapshots/source-list.dump.zon + -Ddump=test/web-snapshots/touch.dump.zon ``` The output is in `zig-out/web`. Serve that directory over HTTP; browsers do not allow a useful WASM module load from `file:` URLs. The browser shell has no argv: every command-line flag `src/main.zig` parses — -`--tty`, `--tty-toggle`, `-n`, `-l`, `--fs[=]`, `--nested`, +`--tty`, `--tty-toggle`, `-n`, `-l`, `--9p=`, `--mount==`, `--nested`, `--detach[=]`, `--attach[=]`, `-h`/`--help`, `--version`, and the positional file-or-directory — is native-only, because the wasm module roots at `src/web.zig` and never links `main.zig`. State comes from the embedded dump @@ -92,7 +92,7 @@ the browser can never call. same gate: both are `enabled = pardes.hosted` (`src/builtins.zig`, and `pardes.hosted` is `tty or gui or macos`), because a detached session is a unix socket and a page has none. Nothing in this shell speaks -`src/detached/wire.zig`, and `push_detach` is one of the null vtable methods +`src/detached/wire.zig`, and `detach` is one of the null vtable methods below. `src/web.zig` fills in six methods of `Host.VTable` and leaves the rest null, @@ -120,8 +120,8 @@ failure; `wait_input` is null because wasm must never block, and `watch_theme` and `dump_themes` are not reachable without a config directory. Of the remaining nulls, `post_present` and `poll_frame` are per-frame host bookkeeping a page has none of, `detach` has no session to leave, `gpio_toggle` belongs to -the ESP32-P4 board, and `fs_reply` answers a FUSE control filesystem that -`--fs` is the only way to ask for. `quit` sets the +the ESP32-P4 board, and native filesystem replies are delivered by the 9P +listener. `quit` sets the core's flag, which `pardes_should_quit` reports so the page can stop its frame loop. @@ -133,7 +133,7 @@ zig build web-harness \ -Dplatform=web \ -Dtarget=wasm32-freestanding \ -Dtree-sitter=zig \ - -Ddump=test/web-snapshots/source-list.dump.zon + -Ddump=test/web-snapshots/touch.dump.zon # Real headless Chrome, DOM rendering, and browser touch input. zig build web-e2e diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index aaaf5920..00000000 --- a/examples/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# examples - -Programs that drive pardes from the outside. Each subdirectory is one -interface; `acmefs/` is the acme control filesystem. - -## The acme control filesystem - -Started with `--fs`, pardes serves a small filesystem describing itself: one -directory per pane, holding the pane's text, its tag, its selection, a control -file of verbs and an event stream. Reading a file asks the editor a question, -writing one gives it an order, and a middle click can be delivered to a script -instead of to the editor. That is the whole of plan9 `acme(4)`, which pardes -follows closely enough that acme's own manual page is the reference; the -divergences are listed at the end. - -The point is that a text editor becomes scriptable by anything that can open a -file. The scripts here are python3 and bash with no dependencies at all, and -none of them link, embed, or know anything about pardes. - -### Starting it - -``` -pardes --fs # mount under $XDG_RUNTIME_DIR/pardes/ -pardes --fs=/tmp/mypardes # or name the mount point yourself -``` - -The mount lives in `$XDG_RUNTIME_DIR/pardes/`, or -`~/.local/state/pardes/` when there is no runtime directory. It is created -at startup, mode 0700, and unmounted and removed on exit; a startup sweep -removes directories left by a pardes that died without unmounting. - -Every shell pardes starts inside a pane inherits two variables: - -| variable | meaning | -|---|---| -| `PARDES_FS` | the mount directory | -| `PARDES_PANE` | the id of the pane the shell is running in | - -So a script run from a pane already knows both where the editor is and which -pane it is talking from, and every example below defaults to those. - -One rule the protocol inherits from acme: **a `ctl` verb, an `addr` expression -and an `event` record must each arrive in ONE `write(2)`.** acme got that for -free (a 9P message IS a write), and every ordinary client has it too — stdio -buffers, `echo` and `dd` write whole strings, Python's `os.write` is one call. -A client that writes a verb one byte at a time gets EINVAL per byte, because a -control file cannot tell a half-finished verb from a wrong one. Write whole -lines. - -### The tree - -``` -/ - index r one line per pane - cons w appends to +Errors - new/ dir looking up ANY name here creates a pane - / dir one per pane; id is the pane serial, never reused - addr body ctl data errors event rdsel tag wrsel xdata - pty/ dir TERMINAL PANES ONLY; absent on a pane with a document - ctl status data -``` - -| file | mode | semantics | -|---|---|---| -| `index` | r | one line per pane: five `%11d` fields -- id, tag length, body length, isdir, dirty -- then the tag text. Seekable. | -| `cons` | w | appended text goes to the `+Errors` pane for the writing pane's directory, created on first write. | -| `new/` | lookup | creates a pane and resolves to that pane's ``, so `echo hi > $PARDES_FS/new/body` opens a pane containing `hi`. Listing `new/` enumerates nothing at all -- every name it could report is a name whose lookup would create a pane, and `ls -l` stats what a listing reports. | -| `addr` | rw | read: the current address as two `%11d` byte offsets. Write: an address expression. Never disturbs the user's selection. | -| `body` | rw | read at any offset; a write APPENDS, whatever the offset. Replacing text means `addr` + `data`. | -| `tag` | rw | read the whole tag; a write appends to the editable tail. (A pardes tag carries a live read-only prefix, so appending to that would be meaningless.) | -| `ctl` | rw | read: the five `index` numbers plus `%11d %q %11d` -- width in cells, font name, tab width in cells. Write: newline separated verbs, several per write, applied all or nothing. | -| `data` | rw | read: whole graphemes from the start of `addr`, up to the read size, moving `addr` past them. Write: replaces the addressed text and leaves `addr` as the null string after the insertion. The file offset is ignored. | -| `xdata` | rw | `data`, except that reads stop at the end of `addr`. | -| `errors` | w | appends to `/+Errors` for this pane's directory. | -| `rdsel` | r | the pane's current selection. | -| `wrsel` | w | replaces the pane's current selection. | -| `event` | rw | the pane's action stream, both directions. See below. | -| `pty/` | dir | present only when the pane is a terminal, so `test -d $PARDES_FS//pty` is how a script asks. Absent — not empty — on a file pane, and every file under it answers ENOENT there. | -| `pty/ctl` | w | newline separated verbs, several per write, applied all or nothing: `winsize `, `sig INT\|TERM\|HUP\|QUIT\|KILL`, `exec`. Anything else is EINVAL and applies nothing. | -| `pty/status` | r | three `%11d` fields: columns, rows, and 1 while a program (vim, a pager, a build) holds the tty rather than the shell prompt. Seekable. | -| `pty/data` | rw | the raw stream. A write is input to the program, offset ignored, short at a character boundary. A read hands back as much of the pending output as the count allows and keeps the rest — raw bytes have no records, so unlike `event` a small read is served rather than refused — and blocks while there is nothing. Output is only recorded while the file is OPEN. | - -`ctl` verbs: `addr=dot`, `clean`, `dirty`, `cleartag`, `del`, `delete`, -`dot=addr`, `get`, `limit=addr`, `mark`, `nomark`, `name `, `noscroll`, -`scroll`, `put`, `show`. An unknown verb fails the whole write with EINVAL and -applies nothing, so a batch is safe to send blind. - -`pty/ctl` verbs in detail. `winsize` is `TIOCSWINSZ` and nothing more: it tells -the program a size and does not move the pane, whose grid is its rectangle on -screen, so the next time you drag that pane the layout's size wins again. -`sig` goes to the tty's foreground process group — what ^C would reach — and -not to the shell, which ignores SIGINT while it waits for a job. `exec` -respawns the pane's configured shell in the pane's own directory and takes NO -argument: `exec /bin/sh` is EINVAL rather than an argument silently dropped. -`raw` and `cooked` do not exist, because the termios belongs to the program on -the far side of the pty and it never tells us. - -Addresses, all in bytes: `#n` an offset, `n` a line, `0` the start, `$` the -end, `.` the selection, `a,b` a range (`,` alone is the whole body), `+` and -`-` with a count or a regex, `/re/` forwards, `?re?` backwards. Anything else -is EINVAL. - -### Events - -A record is two characters and four blank separated decimal numbers, then the -text: - -``` -origin type q0 q1 flag length [text] -``` - -Origin is `E` for a write through the `body` or `tag` file, `F` for an action -through one of the pane's other files, `K` for the keyboard and `M` for the -mouse. Type is `D`/`d` for a delete, `I`/`i` for an insert, `L`/`l` for a -button-3 Look and `X`/`x` for a button-2 Exec -- uppercase for the body, -lowercase for the tag, which is the entire addressing convention. Text of 256 -bytes or more is elided with a length of 0 and can be fetched from `data`. -Deletes carry no text. - -Two behaviours make this the interesting file: - -* **While a pane's event file is open, its Look and Exec are reported and not - performed.** Chorded Cut and Paste still work normally. That is what lets a - script put its own words in the tag and mean its own things by them -- - `acmefs/life.py` is nothing but that trick. -* **Writing a record back performs the action**, as though the event file had - never been open. The write is only `origin type q0 q1` and a newline: the - action is named by a range of the pane's own text. Passing on the records you - do not implement is how a script stays a good citizen of somebody else's - editor. - -### Deliberate divergences from acme - -acme counts runes; pardes counts bytes, clamped to grapheme boundaries. -pardes is byte-addressed end to end -- selections, look spots, LSP offsets -- -and a second coordinate system would add an O(n) scan at every boundary and -make `addr=dot` and `dot=addr` lossy. For ASCII the two are identical. - -`acme`, `draw`, `consctl`, `label` and `editout` are not served. The first four -are rio and plan9 compatibility stubs with nothing behind them here, and -`editout` is the output sink of acme's `Edit` language, which pardes does not -have. - -## The examples - -All four take the mount from `$PARDES_FS` and accept an override, and all four -exit quietly when the pane or the mount goes away, because "the editor exited" -is a normal ending for a program living inside it. - -### `acmefs/clock.py` -- a pane that is a clock - -Creating a pane, naming it, and replacing its body in place once a second. - -``` -$ examples/acmefs/clock.py & -``` - -A pane named `/+clock` appears and fills with the time in doubled-width block -digits. Ctrl-C removes it. Demonstrates: `new/ctl` as the creation handshake, -batched `ctl` verbs, and `addr` + `data` as the only way to replace body text. - -### `acmefs/life.py` -- a game whose buttons are the tag - -``` -$ examples/acmefs/life.py & -``` - -A pane named `/+life` appears with `Step Run Stop Clear Random` in its tag and -a random 40x20 board in its body. Middle-click the words: they are not pardes -commands and pardes has never heard of them, but the script has the event file -open, so the clicks arrive here as `x` records naming the text. Button 3 on a -cell toggles it. Middle-clicking anything the script does not implement -- the -pane's own `Del`, say -- is written back to the event file and performed by the -editor as usual. Ctrl-C removes the pane. - -### `acmefs/pardesctl` -- the editor from the command line - -``` -$ examples/acmefs/pardesctl panes - ID DIRTY BYTES TAG - 1 - 4213 src/pardes.zig - 3 * 118 /+clock -$ examples/acmefs/pardesctl send 3 'hello from the shell' -$ examples/acmefs/pardesctl body 3 | wc -l -$ examples/acmefs/pardesctl tag 1 -$ id=$(examples/acmefs/pardesctl new src/pardes.zig) -$ examples/acmefs/pardesctl exec "$id" Help -$ examples/acmefs/pardesctl exec "$id" 'date >/tmp/from-pardes' -$ examples/acmefs/pardesctl del "$id" -``` - -`exec` is the remote control door: it appends the command to the pane's tag, -works out the byte range it landed in, and writes an `x` record naming that -range -- which is exactly what a middle click on the same text would have sent. -The command stays in the tag afterwards, where acme leaves it too, so it can be -clicked again. -A pardes builtin (`Help`, `New`, `Changelog`, ...) runs as a builtin; anything -else runs as a shell command with its output going to `+Errors`, the same as if -you had typed it into a tag and clicked it. - -`-m ` overrides `$PARDES_FS`. With no arguments it prints its usage. - -### `acmefs/eventlog` -- watch the protocol - -``` -$ examples/acmefs/eventlog 3 -ORIGIN ACTION WHERE Q0 Q1 FLAG TEXT -mouse exec tag 41 45 builtin Help -fs-write insert body 0 0 - (no text...) -``` - -Every record spelled out in words, flag bits included. Point it at a pane and -type in it, click in it, write to it from `pardesctl`, and watch what the -editor reports. - -## WARNING - -**Opening a pane's `event` file suppresses that pane's Look and Exec.** Button -2 and button 3 in a watched pane are reported to the reader and are *not* -performed by the editor, so a watched pane feels broken until the reader exits -(chorded Cut and Paste are exempt). This is a feature -- it is what makes a -script's own tag words possible -- but `eventlog` inherits it, so do not leave -it attached to a pane you are trying to work in. - -**A record is consumed by whoever reads it first.** Two programs on one pane's -event file split the stream between them and both misbehave. Do not point -`eventlog` at the pane `life.py` is driving. - -**Writing an `X` or `x` record executes arbitrary commands, by design.** So -does `Look` reaching an executable name. `pardesctl exec` is four lines of -shell for a reason: the filesystem is a remote control, and anything that -can write into the mount directory can run commands as you. The mount is mode -0700 under your own runtime directory, and that is the only thing standing -between the two facts. Do not put it on a shared filesystem, and do not serve -it to anything you would not hand a shell to. diff --git a/examples/acmefs/clock.py b/examples/acmefs/clock.py deleted file mode 100755 index d976544b..00000000 --- a/examples/acmefs/clock.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""A pardes pane that becomes a live clock, driven only through the acme -control filesystem. python3 stdlib, nothing else. - -WHAT IT DEMONSTRATES - - * Creating a pane is a LOOKUP, not a write: naming any file under `new/` - makes a pane and resolves to that pane's copy of the file. Opening - `new/ctl` is therefore the whole creation handshake, because the ctl read - hands back the new pane's id as its first field. Nothing else in the tree - can create a pane, and READDIR of `new/` creates nothing. - - * The `ctl` verb stream: `name` and `clean` go out in ONE write, newline - separated. ctl writes are all-or-nothing, so a batch either applies whole - or leaves the pane untouched -- which is why sending the pair together is - safer than two writes that could half-fail. - - * `addr` + `data` as a whole-body REPLACE. A `body` write always appends - (the offset is ignored), so redrawing a frame in place needs the address - machinery: write `,` to `addr` to select the entire body, then write the - frame to `data`, which substitutes the addressed text. After that write - `addr` is the null string just past the insertion, so if the kernel splits - a big frame across several write(2) calls the pieces still land in order: - the first replaces, the rest append at the growing end. - -FILES TOUCHED - - new/ctl create the pane, read its id back - /ctl `name /+clock`, `clean` - /addr `,` (whole body) before each frame - /data the frame itself - /ctl `clean` again after each frame, see below - -Every frame ends with `clean` because a data write marks the pane dirty, and a -generated clock face is not user data: a dirty pane refuses `del` and nags on -exit. One extra ctl round trip per second is not a cost worth optimising. - -USAGE - - clock.py [mountdir] default: $PARDES_FS (set in every pane shell) - -Ctrl-C removes the pane and exits. So does the pane being deleted from the -editor: the next addr/data write fails with an OSError, which is the only -"the other end is gone" signal the filesystem gives us, and it is enough. -""" - -import os -import sys -import time - -# 3x5 cells per glyph, doubled horizontally below so the face is legible in a -# character grid, where cells are about twice as tall as they are wide. -FONT = { - "0": ("###", "# #", "# #", "# #", "###"), - "1": (" #", " #", " #", " #", " #"), - "2": ("###", " #", "###", "# ", "###"), - "3": ("###", " #", "###", " #", "###"), - "4": ("# #", "# #", "###", " #", " #"), - "5": ("###", "# ", "###", " #", "###"), - "6": ("###", "# ", "###", "# #", "###"), - "7": ("###", " #", " #", " #", " #"), - "8": ("###", "# #", "###", "# #", "###"), - "9": ("###", "# #", "###", " #", "###"), - ":": (" ", " # ", " ", " # ", " "), -} -BLANK = (" ",) * 5 -XSCALE = 2 - - -def art(text): - """Render `text` as five rows of doubled-width block characters.""" - rows = [] - for row in range(5): - line = " ".join(FONT.get(ch, BLANK)[row] for ch in text) - rows.append("".join(ch * XSCALE for ch in line).rstrip()) - return rows - - -def write_all(fd, data): - """One logical fs write. Short writes are looped over rather than trusted - away: see the addr/data note in the module comment for why the tail of a - split frame still lands in the right place.""" - view = memoryview(data) - while view: - view = view[os.write(fd, view) :] - - -def main(argv): - mount = argv[1] if len(argv) > 1 else os.environ.get("PARDES_FS", "") - if not mount: - sys.stderr.write( - "clock.py: no mount point. Pass one, or run inside a pardes pane\n" - " shell where $PARDES_FS is set (start pardes with --fs).\n" - ) - return 1 - if not os.path.isdir(mount): - sys.stderr.write("clock.py: %s is not a directory\n" % mount) - return 1 - - # The lookup of `new/ctl` is the creation. Read it back for the id, which - # is the first of the five index numbers (id, tag len, body len, isdir, - # dirty) that a ctl read starts with. acme's ctl read has no trailing - # newline, so read the lot and split on whitespace rather than a line. - try: - with open(os.path.join(mount, "new", "ctl"), "rb") as f: - fields = f.read(256).split() - except OSError as e: - sys.stderr.write("clock.py: cannot create a pane: %s\n" % e) - return 1 - if not fields or not fields[0].isdigit(): - sys.stderr.write("clock.py: unexpected new/ctl contents: %r\n" % fields[:1]) - return 1 - pane = fields[0].decode() - - d = os.path.join(mount, pane) - ctl = addr = data = None - try: - # O_WRONLY, never O_TRUNC: truncating a control file is a setattr the - # server has no reason to honour, and `open(..., "wb")` would send one. - ctl = os.open(os.path.join(d, "ctl"), os.O_WRONLY) - write_all(ctl, b"name /+clock\nclean\n") - addr = os.open(os.path.join(d, "addr"), os.O_WRONLY) - data = os.open(os.path.join(d, "data"), os.O_WRONLY) - - while True: - now = time.localtime() - frame = art(time.strftime("%H:%M:%S", now)) - frame.append("") - frame.append(time.strftime("%A %d %B %Y", now)) - payload = ("\n".join(frame) + "\n").encode() - write_all(addr, b",") - write_all(data, payload) - write_all(ctl, b"clean\n") - # Sleep to the next second boundary so the face never skips or - # stutters, and so this loop is never a spin. - time.sleep(1.0 - (time.time() % 1.0)) - except KeyboardInterrupt: - pass - except OSError: - # The pane (or the whole mount) went away. That is a normal ending for - # a script that lives inside someone else's editor, not a crash. - return 0 - finally: - for fd in (addr, data): - if fd is not None: - try: - os.close(fd) - except OSError: - pass - if ctl is not None: - try: - write_all(ctl, b"clean\ndel\n") - except OSError: - pass - try: - os.close(ctl) - except OSError: - pass - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/examples/acmefs/eventlog b/examples/acmefs/eventlog deleted file mode 100755 index b7c7596a..00000000 --- a/examples/acmefs/eventlog +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -# Stream one pane's `event` file and print every record in words, so that the -# protocol can be watched instead of guessed at. bash and coreutils only. -# -# WARNING -- THIS IS NOT A PASSIVE OBSERVER -# -# Two things change the moment this script starts. -# -# While a pane's event file is open, that pane's button-2 (Exec) and button-3 -# (Look) actions are REPORTED and NOT PERFORMED. Middle-clicking Del in the -# tag of a watched pane will print a record here and do nothing to the pane. -# (Chorded Cut and Paste are exempt and behave normally.) That suppression is -# the feature -- it is what lets a script define its own tag commands -- but -# while you are only watching, it makes the pane feel broken. -# -# And a record is consumed by whoever reads it first. If another program is -# driving that pane through its event file, do not point this at the same pane: -# the two readers will split the stream and both will misbehave. Watch a pane -# nobody owns, or watch the script instead. -# -# WHAT IT DEMONSTRATES -# -# A record is two characters -- origin and type -- then four blank separated -# decimal numbers (q0, q1, flag, text length) and the text. Uppercase types -# refer to the body, lowercase to the tag; that single bit of case is the whole -# addressing scheme. This script spells all of it out: `M X` prints as -# "mouse exec body", and the flag bits print as the words they stand for. -# -# The event file is read through `cat` rather than opened by the shell. bash's -# `read` buffers from a seekable fd and then seeks back to correct the file -# position -- fine on a real file, silently lossy on a stream whose server -# ignores offsets. `cat` reads strictly forward, and the pipe it writes into is -# not seekable, so nothing can be skipped. -# -# FILES TOUCHED: /event (read only, but see the warning). -# -# USAGE: eventlog [-m mountdir] [pane-id] default id: $PARDES_PANE -set -u -LC_ALL=C # so ${#text} counts BYTES: event offsets are byte offsets - -self=${0##*/} -mount=${PARDES_FS:-} - -if [ "${1:-}" = "-m" ]; then - [ $# -ge 2 ] || { echo "$self: -m needs a directory" >&2; exit 2; } - mount=$2 - shift 2 -fi -pane=${1:-${PARDES_PANE:-}} - -if [ -z "$mount" ] || [ -z "$pane" ]; then - cat >&2 <&2; exit 2 ;; -esac -ev="$mount/$pane/event" -[ -r "$ev" ] || { echo "$self: cannot read $ev (no such pane?)" >&2; exit 1; } - -origin_word() { - case $1 in - E) echo "fs-write" ;; # a write to this pane's body or tag - F) echo "fs-action" ;; # an action taken through another of its files - K) echo "keyboard" ;; - M) echo "mouse" ;; - *) echo "origin?$1" ;; - esac -} - -# Uppercase = body, lowercase = tag. Nothing else distinguishes the two. -type_word() { - case $1 in - D | I | L | X) echo "body" ;; - d | i | l | x) echo "tag" ;; - *) echo "?" ;; - esac -} - -action_word() { - case $1 in - D | d) echo "delete" ;; - I | i) echo "insert" ;; - L | l) echo "look" ;; # button 3 - X | x) echo "exec" ;; # button 2 - *) echo "type?$1" ;; - esac -} - -# The flag is a bitwise OR whose meaning depends on the type. Deletes and -# inserts always carry 0, so only look and exec decode to anything. -flag_words() { - local t=$1 f=$2 out="" - case $t in - X | x) - (((f & 1) != 0)) && out="$out,builtin" - (((f & 2) != 0)) && out="$out,expanded(record follows)" - (((f & 8) != 0)) && out="$out,chorded-arg(2 records follow)" - ;; - L | l) - (((f & 1) != 0)) && out="$out,no-load-needed" - (((f & 2) != 0)) && out="$out,expanded(record follows)" - (((f & 4) != 0)) && out="$out,file-or-pane-name" - ;; - esac - [ -n "$out" ] && printf '%s' "${out#,}" || printf -- '-' -} - -printf '%-10s %-7s %-7s %8s %8s %-24s %s\n' ORIGIN ACTION WHERE Q0 Q1 FLAG TEXT -cat -- "$ev" 2>/dev/null | while IFS= read -r line; do - # Blank lines are a record terminator, not a record: skip them. This is - # also what keeps the reader in step with either text layout below. - [ -n "$line" ] || continue - o=${line:0:1} - t=${line:1:1} - rest=${line:2} - # shellcheck disable=SC2034 - read -r q0 q1 flag n text <<<"$rest" || : - q0=${q0:-0} q1=${q1:-0} flag=${flag:-0} n=${n:-0} text=${text:-} - case $n in *[!0-9]*) n=0 ;; esac - if [ "$n" -gt 0 ] && [ -z "$text" ]; then - # The counted bytes follow the newline. - IFS= read -r -N "$n" text || : - elif [ "$n" -gt 0 ] && [ "${#text}" -lt "$n" ]; then - # The text sat on the record line and contained a newline of its - # own, which the line read above swallowed. Take the remainder. - want=$((n - ${#text} - 1)) - more="" - [ "$want" -gt 0 ] && { IFS= read -r -N "$want" more || :; } - text="$text -$more" - fi - if [ -n "$text" ]; then - shown=$(printf '%q' "$text") - elif [ "$n" -gt 0 ]; then - shown="(short by $n bytes: the stream ended mid-record)" - else - # Count 0 means "no text was sent". For a delete that is the rule; - # for a look or an exec it means the text was 256 bytes or longer and - # was elided, or the selection was null and an expansion follows. - case $t in - X | x | L | l) shown="(no text: elided, or null -- read $pane/data)" ;; - *) shown="" ;; - esac - fi - printf '%-10s %-7s %-7s %8s %8s %-24s %s\n' \ - "$(origin_word "$o")" "$(action_word "$t")" "$(type_word "$t")" \ - "$q0" "$q1" "$(flag_words "$t" "$flag")" "$shown" -done -# cat ends when the pane or the whole mount goes away. That is the editor -# exiting, not a failure, so say nothing and leave with 0. -exit 0 diff --git a/examples/acmefs/life.py b/examples/acmefs/life.py deleted file mode 100755 index 09318f6d..00000000 --- a/examples/acmefs/life.py +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env python3 -"""Conway's Game of Life whose entire user interface is the pane's TAG. -python3 stdlib, nothing else. - -WHAT IT DEMONSTRATES - -This is the acme trick that makes the filesystem worth having: a script can -define its own commands without the editor knowing anything about them. - - 1. Write words into the pane's `tag`. They are now just text. - 2. Open the pane's `event` file. While it is open, button-2 (Exec) and - button-3 (Look) on that pane are REPORTED to us and NOT performed by the - editor. (Chorded Cut/Paste keep working, so the tag stays editable.) - 3. A middle click on `Run` therefore arrives here as an `x` record naming - that text, and "Run" means whatever this script decides it means. - -Words we do not recognise are WRITTEN BACK to the event file unchanged, which -makes the editor perform the action as though the event file had never been -open. So the pane's own tag entries -- Del, Put, whatever the editor puts -there -- still work while we are attached. A script that swallowed them would -be a black hole; passing them through is the whole etiquette of the protocol. - -A button-3 click in the BODY (an `L` record) toggles the cell under the click. -The body is rendered as exactly H lines of W cells plus a newline each, so the -click offset q0 maps to a cell by plain division -- no coordinate lookup, no -round trip. Anything decorative goes BELOW the grid, where it cannot disturb -that arithmetic. - -FILES TOUCHED - - new/ctl create the pane, read its id back - /ctl `name /+life`, `clean` - /tag the command words -- a tag write appends to the editable tail - /event O_RDWR: blocking reads for records, writes to pass records on - /addr `,` (whole body) before each generation - /data the generation itself - -USAGE - - life.py [mountdir] default: $PARDES_FS (set in every pane shell) - - Step one generation Clear empty the grid - Run animate Random fill the grid at random - Stop stop animating button 3 in the grid: toggle that cell - -Ctrl-C removes the pane and exits. So does the pane being deleted: the next -write fails, or the event reader hits end of file, and either is a clean end. - -WHY A THREAD - -Event reads BLOCK -- the server holds the request until a record exists -- and -a FUSE-backed regular file always polls readable, so select() cannot be used to -wait on one. Life also has to advance on a timer. So one daemon thread does -nothing but blocking reads and hands records to a Queue, and the main loop -waits on the Queue with a deadline. That keeps the blocking read where it -belongs and leaves the main loop free of spin. -""" - -import os -import queue -import random -import sys -import threading -import time - -W, H = 40, 20 -TICK = 0.15 -LIVE, DEAD = "#", "." -COMMANDS = ("Step", "Run", "Stop", "Clear", "Random") - - -class Records: - """Counted event records off a blocking fd, kept in step byte-exactly. - - The record is two characters (origin, type), then four blank separated - decimal numbers -- q0, q1, flag, text length -- then the text. - - Two layouts exist in the wild: plan9 acme puts the text before the - record's terminating newline, while the pardes design note writes the - newline after the four numbers and the counted bytes after it. Both are - accepted here. Guessing wrong would not mangle one record, it would - desynchronise the stream forever, so this reader takes whatever the line - still holds as text and only goes back to the fd for bytes the count says - are missing. Blank lines are skipped, which absorbs either layout's - record terminator. - """ - - def __init__(self, fd): - self.fd = fd - self.buf = b"" - - def _fill(self): - chunk = os.read(self.fd, 4096) # blocks in the server until a record - if not chunk: - raise EOFError("event file closed") - self.buf += chunk - - def _line(self): - while True: - nl = self.buf.find(b"\n") - if nl >= 0: - line, self.buf = self.buf[:nl], self.buf[nl + 1 :] - if line: - return line - continue - self._fill() - - def _take(self, n): - while len(self.buf) < n: - self._fill() - out, self.buf = self.buf[:n], self.buf[n:] - return out - - def next(self): - line = self._line() - while len(line) < 2: - line = self._line() - origin, typ = chr(line[0]), chr(line[1]) - rest, nums, i = line[2:], [], 0 - for _ in range(4): - while i < len(rest) and rest[i : i + 1] == b" ": - i += 1 - j = i - while j < len(rest) and rest[j : j + 1].isdigit(): - j += 1 - nums.append(int(rest[i:j]) if j > i else 0) - i = j - q0, q1, flag, count = nums - tail = rest[i + 1 :] if rest[i : i + 1] == b" " else rest[i:] - if count == 0: - # Text of 256 bytes or more is elided: count 0 and no bytes. The - # reader is meant to fetch it from `data` if it cares; we do not. - text = tail - elif tail: - text = tail - if len(text) < count: - text += b"\n" # the newline we stopped on belongs to the text - text += self._take(count - len(text)) - else: - text = self._take(count) - return (origin, typ, q0, q1, flag, text.decode("utf-8", "replace")) - - -def write_all(fd, data): - view = memoryview(data) - while view: - view = view[os.write(fd, view) :] - - -class Life: - def __init__(self, mount): - with open(os.path.join(mount, "new", "ctl"), "rb") as f: - fields = f.read(256).split() - if not fields or not fields[0].isdigit(): - raise OSError("unexpected new/ctl contents: %r" % fields[:1]) - self.pane = fields[0].decode() - d = os.path.join(mount, self.pane) - self.ctl = os.open(os.path.join(d, "ctl"), os.O_WRONLY) - write_all(self.ctl, b"name /+life\nclean\n") - self.tagfd = os.open(os.path.join(d, "tag"), os.O_RDWR) - # O_RDWR on one fd: reading records and writing them back are the two - # halves of one conversation, and the editor's "someone is listening" - # state follows the open, so a second open would be a second listener. - self.event = os.open(os.path.join(d, "event"), os.O_RDWR) - self.addr = os.open(os.path.join(d, "addr"), os.O_WRONLY) - self.data = os.open(os.path.join(d, "data"), os.O_WRONLY) - write_all(self.tagfd, (" " + " ".join(COMMANDS)).encode()) - self.tagtext = None - self.passed = None - self.cells = set() - self.gen = 0 - self.running = False - - def close(self): - for fd in (self.tagfd, self.event, self.addr, self.data): - try: - os.close(fd) - except OSError: - pass - try: - write_all(self.ctl, b"clean\ndel\n") - except OSError: - pass - try: - os.close(self.ctl) - except OSError: - pass - - # --- the fs side ----------------------------------------------------- - - def render(self): - rows = [] - for y in range(H): - rows.append("".join(LIVE if (x, y) in self.cells else DEAD for x in range(W))) - rows.append("") - rows.append( - "generation %d %s %d alive button 3 in the grid toggles a cell" - % (self.gen, "running" if self.running else "stopped", len(self.cells)) - ) - write_all(self.addr, b",") - write_all(self.data, ("\n".join(rows) + "\n").encode()) - write_all(self.ctl, b"clean\n") - - def tag_slice(self, q0, q1): - """The text of a tag click, for the case where the record carried none. - Read once and cached: the tag only changes when we or the user change - it, and a wrong guess here costs an ignored click, not a corruption.""" - if self.tagtext is None: - self.tagtext = os.pread(self.tagfd, 8192, 0).decode("utf-8", "replace") - return self.tagtext[q0:q1] - - def passthrough(self, origin, typ, q0, q1): - """Hand a record we do not implement back to the editor, which then - performs it exactly as if nobody had been listening. Flag, count and - text are omitted: the two characters and two numbers are the whole - identity of the action. - - The coordinates are remembered so that a record coming straight back - at us can be recognised. A correct server does not re-report an action - it was asked to perform -- acme marks the write-back path `external` - precisely to skip its own reporting branch -- but if one ever did, a - passthrough of a passthrough is an infinite loop, and this is a cheaper - insurance policy than finding that out in someone's editor.""" - self.passed = (typ, q0, q1) - write_all(self.event, ("%c%c%d %d\n" % (origin, typ, q0, q1)).encode()) - - # --- the game side --------------------------------------------------- - - def step(self): - counts = {} - for (x, y) in self.cells: - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - if dx or dy: - n = ((x + dx) % W, (y + dy) % H) - counts[n] = counts.get(n, 0) + 1 - self.cells = {c for c, n in counts.items() if n == 3 or (n == 2 and c in self.cells)} - self.gen += 1 - - def command(self, word): - if word == "Step": - self.step() - elif word == "Run": - self.running = True - elif word == "Stop": - self.running = False - elif word == "Clear": - self.cells.clear() - self.gen = 0 - elif word == "Random": - self.cells = { - (x, y) for x in range(W) for y in range(H) if random.random() < 0.28 - } - self.gen = 0 - else: - return False - return True - - def toggle(self, q0): - """Body offset -> cell. Rows are W cells plus a newline, so the row is - the quotient and the column the remainder; a click on the newline, or - anywhere in the status line below the grid, lands outside and is - ignored.""" - row, col = divmod(q0, W + 1) - if row >= H or col >= W: - return - cell = (col, row) - self.cells.symmetric_difference_update({cell}) - - def handle(self, rec): - origin, typ, q0, q1, _flag, text = rec - if not text and (typ, q0, q1) == self.passed: - return False # a record we passed on, coming back: see passthrough - if typ in "xX": - word = (text or self.tag_slice(q0, q1)).strip() - if not self.command(word): - self.passthrough(origin, typ, q0, q1) - return False - elif typ == "L": - self.toggle(q0) - elif typ == "l": - self.passthrough(origin, typ, q0, q1) - return False - else: - return False # I/i/D/d: our own writes echoing back - return True - - -def reader(records, q): - try: - while True: - q.put(records.next()) - except (OSError, EOFError, ValueError): - pass - q.put(None) # the pane or the mount is gone - - -def main(argv): - mount = argv[1] if len(argv) > 1 else os.environ.get("PARDES_FS", "") - if not mount: - sys.stderr.write( - "life.py: no mount point. Pass one, or run inside a pardes pane\n" - " shell where $PARDES_FS is set (start pardes with --fs).\n" - ) - return 1 - if not os.path.isdir(mount): - sys.stderr.write("life.py: %s is not a directory\n" % mount) - return 1 - try: - game = Life(mount) - except OSError as e: - sys.stderr.write("life.py: cannot set up a pane: %s\n" % e) - return 1 - - q = queue.Queue() - threading.Thread(target=reader, args=(Records(game.event), q), daemon=True).start() - try: - game.command("Random") - game.render() - deadline = time.monotonic() + TICK - while True: - if game.running: - wait = deadline - time.monotonic() - if wait <= 0: - game.step() - game.render() - deadline = time.monotonic() + TICK - wait = TICK - else: - wait = 0.25 # a bounded wait, not a spin: clicks stay prompt - try: - rec = q.get(timeout=wait) - except queue.Empty: - continue - if rec is None: - break - if game.handle(rec): - game.render() - deadline = time.monotonic() + TICK - except KeyboardInterrupt: - pass - except OSError: - return 0 # the pane went away mid-write; nothing to report - finally: - game.close() - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/examples/acmefs/pardesctl b/examples/acmefs/pardesctl deleted file mode 100755 index 0a7bc1a4..00000000 --- a/examples/acmefs/pardesctl +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# A tiny command line over the pardes acme filesystem. bash and coreutils only: -# every subcommand below is one or two ordinary file operations, which is the -# point of serving the editor as a filesystem in the first place. -# -# WHAT IT DEMONSTRATES -# -# panes read `index` -- five %11d numbers (id, tag length, body length, -# isdir, dirty) then the tag text, one line per pane -# body read `/body` tag read `/tag` -# send APPEND to `/body` -- a body write ignores its offset, so there -# is no such thing as a partial overwrite by accident -# exec write an `X`/`x` event record, which makes the editor perform the -# action as though nobody had been listening. This is the remote -# control door: it runs pardes builtins and shell commands alike. -# new LOOKUP under `new/`, which is what creates a pane -# del ctl verb `del` -# -# TWO RULES THIS SCRIPT FOLLOWS, AND YOU SHOULD TOO -# -# Always `>>`, never `>`. A plain `>` opens O_TRUNC, which is a setattr with -# size 0 -- a truncate request against a control file. Appending is what every -# writable file here actually wants; the offset is ignored anyway. -# -# `ctl` and `new/ctl` reads carry no trailing newline (acme prints fields, not -# lines), so `read` returns non-zero at end of file even though it has already -# assigned the fields. Hence the `|| :` on those reads. -# -# HOW `exec` RUNS ARBITRARY TEXT -# -# An event write is only `origin type q0 q1` -- no text. The action is named by -# a range of the pane's own text, lowercase type for the tag and uppercase for -# the body. So to run a command that is not on screen yet, this script appends -# it to the tag (a tag write appends to the editable tail), measures where it -# landed, and executes exactly that range. The command text stays visible in -# the tag afterwards, which is also how acme leaves it, and means the user can -# click it again. -# -# USAGE: run with no arguments. -set -u - -self=${0##*/} -mount=${PARDES_FS:-} - -usage() { - cat >&2 < print the pane's text - tag print the pane's tag - send append a line of text to the pane's body - exec make the editor run (builtin or shell command) - new [file] create a pane, optionally loading ; prints its id - del delete the pane (refused if it has unsaved changes) - -The mount directory comes from \$PARDES_FS, which pardes sets in every pane -shell when started with --fs, or from -m. \$PARDES_PANE is the id of the pane -a shell is running in, so "$self send \$PARDES_PANE hello" talks to itself. -EOF - exit 2 -} - -die() { printf '%s: %s\n' "$self" "$*" >&2; exit 1; } - -if [ "${1:-}" = "-m" ]; then - [ $# -ge 2 ] || usage - mount=$2 - shift 2 -fi -[ $# -ge 1 ] || usage -[ -n "$mount" ] || die "no mount point: set \$PARDES_FS or pass -m " -[ -d "$mount" ] || die "$mount is not a directory (has pardes exited?)" - -cmd=$1 -shift - -# Every pane file lives under //. Sets $d rather than printing it: -# inside a command substitution `die` would exit only the subshell and the -# caller would sail on with an empty path. Refuses anything that is not a plain -# number, so a typo cannot wander out of the mount. -pane_dir() { - case ${1:-} in - "" | *[!0-9]*) die "expected a pane id (see: $self panes)" ;; - esac - [ -d "$mount/$1" ] || die "no pane $1 (see: $self panes)" - d="$mount/$1" -} - -case $cmd in -panes) - printf '%6s %5s %8s %s\n' ID DIRTY BYTES TAG - while read -r id taglen bodylen isdir dirty tag; do - [ -n "${id:-}" ] || continue - printf '%6s %5s %8s %s\n' \ - "$id" "$([ "${dirty:-0}" = 0 ] && echo - || echo '*')" \ - "${bodylen:-0}" "${tag:-}" - done <"$mount/index" - ;; -body) - pane_dir "${1:-}" - cat -- "$d/body" - ;; -tag) - # A tag read carries no trailing newline, so supply one for the terminal. - pane_dir "${1:-}" - printf '%s\n' "$(cat -- "$d/tag")" - ;; -send) - pane_dir "${1:-}" - shift - [ $# -ge 1 ] || die "send: nothing to send" - printf '%s\n' "$*" >>"$d/body" || die "send: pane went away" - ;; -exec) - pane_dir "${1:-}" - shift - [ $# -ge 1 ] || die "exec: no command" - text=$* - # Where the command will land: byte length of the whole tag, plus the one - # space we prefix so it cannot merge with the word before it. Byte length, - # not character length, because addresses are byte offsets -- ${#text} - # would count characters and mis-address any non-ASCII command. - q0=$(($(wc -c <"$d/tag") + 1)) - q1=$((q0 + $(printf '%s' "$text" | wc -c))) - printf ' %s' "$text" >>"$d/tag" || die "exec: pane went away" - # Lowercase 'x' is a tag exec; origin 'M' reports it as the mouse action - # this stands in for. The editor now runs it. - printf 'Mx%d %d\n' "$q0" "$q1" >>"$d/event" || die "exec: pane refused the event" - ;; -new) - # The lookup itself creates the pane; the ctl read names it. - read -r id _ <"$mount/new/ctl" || : - case ${id:-} in - "" | *[!0-9]*) die "new: unexpected new/ctl contents" ;; - esac - if [ $# -ge 1 ] && [ -n "$1" ]; then - # `name` then `get`: one all-or-nothing ctl write, so the pane is - # never left named after a file it did not load. - printf 'name %s\nget\n' "$1" >>"$mount/$id/ctl" || - die "new: cannot load $1 into pane $id" - fi - printf '%s\n' "$id" - ;; -del) - pane_dir "${1:-}" - printf 'del\n' >>"$d/ctl" || - die "del: pane $1 refused (unsaved changes; save it, or use: $self exec $1 Delete)" - ;; --h | --help | help) - usage - ;; -*) - die "unknown command: $cmd (run with no arguments for usage)" - ;; -esac diff --git a/next-steps.txt b/next-steps.txt index 60605edc..d568c1c8 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -1,84 +1,12 @@ -WHAT THIS IS: a wishlist, in the author's own words. The items below are left -exactly as written; this header is the only thing kept in sync with the code, -because a list that reports shipped work as pending is worse than no list. +This is the original wishlist, not an implementation specification. -9P (docs/9p.typ is the note, docs/registry.typ the argument). SIX NEW THINGS, -each verified against a running program and not assumed: +Current filesystem behavior is documented in docs/fs.md: default Unix 9P, +optional TCP and QUIC, runtime Mount/Unmount, and OS-first Look resolution. +FUSE and the old proof-of-concept examples have been removed. Terminal control +and nested Look use 9P; a board build does not imply hardware validation. -1. A DETACHED SESSION CAN BE SCRIPTED. `pardes --detach=work --fs` mounts its - own /dev/fuse and polls it in the same poll(2) as its frontends and pane - shells, so it needs no wake thread — less than the desktop shells pay. The - one configuration whose panes outlive every terminal was the one no script - could reach. Proof: examples/acmefs/pardesctl panes|new|send|body|del. - -2. TERMINALS ARE SCRIPTABLE. Every terminal pane grows `pty/ctl`, `pty/status` - and `pty/data`. `winsize 80 24`, `sig INT|TERM|HUP|QUIT|KILL`, `exec`; a - write to `pty/data` is input to the process and a read is its RAW output, - escape sequences and all. A script could only ever write into a terminal - that already existed; now it can start one, resize one and signal one. - -3. THE TREE IS SERVED OVER 9P. `--fs9` puts acme's control filesystem on a unix - socket as base 9P2000 (src/9p.zig — codec and a sans-io server, no threads, - no allocator, builds freestanding). plan9port drives it: `9p -a ls /`, - `read /index`, `write /1/body`, `stat`, a walk through `..`. Errors come back - as STRINGS Linux's table knows, not numbers. `--fs` and `--fs9` serve the - same tree at once: a write over one reads back through the other. - -4. A PATHNAME WITHOUT MOUNTING ANYTHING OURSELVES. `9pfuse ` and - ordinary tools work — ls -l with the right modes, cat, shell `>>`, permission - denied on the write-only files. That is the route macOS and the browser take, - neither of which has ever had a control filesystem. - -5. ONE PARDES READS ANOTHER. The `9p ` word walks another - instance's tree and opens the bytes in a pane. Before this, two instances - could shout one line at each other (nested.zig, write-only, no reply) or - REPLACE one another (`Attach` deinits the local core); they could not ask a - question. Proof: B read A's /1/body byte-identically to plan9port. - -6. THE BOARD SERVES ITS OWN TREE. A second ESP32-P4 image where UART0 carries - 9P and nothing else — no ANSI, no vaxis, no allocator — 88 KB against the - console image's 809 KB. Its GPIO tree comes from a comptime table - (src/board9p.zig) that is the single description: one entry fans out to a - directory per pin. The console firmware is untouched and still what - `-Dplatform=esp32p4` feeds. No board was attached, so only what builds and - what the host tests cover is claimed. - -AND ONE THING THAT WENT AWAY: pardes no longer depends on the ../05-zig-p4 -toolchain checkout to build. It used to be a path dependency named in -build.zig.zon, and because `@import` in a build script resolves when the SCRIPT -compiles rather than when the branch needing it is taken, anyone without that -sibling could not build pardes at all — not the firmware, the terminal shell. -The object is the seam: `-Dplatform=esp32p4` emits it here with no toolchain, -and the toolchain repository links the flashable images, which it already knew -how to do. - -SHIPPED (verified against the tree, not assumed): -- "version tracking" -> done. `build.zig` reads `.version` from build.zig.zon - via an untyped `@import`, `gitCommit(b)` reads the commit at configure time, - both reach `pardes_config`, and `pardes --version` prints them. -- "markdown treesitter highlight with nested codeblocks" -> done; markdown is - in `src/grammar_manifest.zig`. -- builtin `Changelog` -> done; `src/builtins.zig`. -- builtin `Newtty` -> done; `src/builtins.zig`. -- builtin `Joincol` -> done; `src/builtins.zig`. -- "only create a new column if both panes get at least 100 columns" -> done; - `pardes.zig` calls `p.columnFitsHalves(from_id, 100)` on the open path. -- "in a tty pane if the leaf process is a shell, ESC behaves as SHIFT-ESC" - -> done, in c3d0b84. `pardes.zig`: "A shell prompt is a pane you can leave: - plain Esc there is Shift-Esc", guarded by `takesCommandLine`, which is - `pane.isTerminal() and !p.hostTtyTaken(id)` — exactly "the leaf is a shell - and not a full-screen program". I first recorded this as PENDING by reading - the Shift-Esc CHORD handling thirty lines above it and concluding from the - absence there; the note is kept because the mistake is instructive. - -STILL PENDING, checked rather than guessed: -- `.patch`/`.diff` highlighting: no such grammar or extension in the tree. -- the tty escape-scanning `tests:` item, and the tty-colour bug: neither - confirmed fixed. The OTHER `tests:` item — "clipboard integration for tty - escape codes should work" — has shipped in both directions: `src/tty/tty.zig` - mirrors a yank register out via OSC 52 and reads the other way, the reply - arriving as an ordinary paste. ("neither" covered three items here until an - audit counted them.) +Still unverified here: patch/diff highlighting and the historical TTY color +and escape-scanning reports below. Keep new regression evidence with its test. meta: - version tracking @@ -103,4 +31,4 @@ bug fix: tests: - if tty escape scanning is active (chosen when libvaxis is created) it should always use all the features it detects by default. -- clipboard integration for tty escape codes should work. \ No newline at end of file +- clipboard integration for tty escape codes should work. diff --git a/src/9p.zig b/src/9p.zig index 068d0740..61a08b84 100644 --- a/src/9p.zig +++ b/src/9p.zig @@ -1,120 +1,15 @@ -//! BASE 9P2000, ON THE WIRE AND NOTHING ELSE: the twenty-seven message types -//! of the original protocol, encoded into a caller's buffer and decoded back -//! out of one, with no allocator, no descriptor and no opinion about what any -//! message means. -//! -//! WHY A SECOND CODEC in a tree that already has `src/detached/wire.zig`. That -//! one is ours on both ends and can be renumbered by editing one file. This one -//! is somebody else's: plan9port's `9p` command, Plan 9's own `mount`, Linux's -//! v9fs and `ad` will all be talking to it, and not one of them will be -//! rebuilt to suit us. So every number below is copied from a primary source -//! with the file and line named, and the tests at the bottom assert LITERAL -//! BYTES against `u9fs/convS2M.c` rather than only round-tripping — a codec -//! that agrees with itself has proved nothing about interoperability. -//! -//! WHAT THIS DELIBERATELY IS NOT. There are three dialects; this is the first. -//! * 9P2000.u adds a numeric errno to `Rerror`, `n_uid`/`extension` to -//! `stat`, and Unix-flavoured `Tcreate`. We do not serve it. The tree is -//! acme's and it is INVENTED — every error in it is one we chose the -//! wording of, so a string is the whole error ABI and a number beside it -//! would be a second spelling of a decision we already made -//! (docs/registry.typ `9P-4`). It also costs a second dialect inside every -//! one of the parsers below, because `.u` changes the LAYOUT of `Rerror` -//! and `stat` rather than adding messages. -//! * 9P2000.L replaces most of the protocol: `Tstatfs`, `Tlopen`, `Tgetattr`, -//! `Tsymlink`, `Trename`, thirty-odd types and a POSIX file model. Our tree -//! has no symlinks, no hard links, no device nodes and no block counts to -//! report, so there is nothing on the other side of those messages to -//! answer them with. -//! * `Tsession`/`Tattach`-with-auth-blob from the 9P1 era, which u9fs still -//! carries commented out (`convS2M.c:60-65,140-148`) and which no client -//! built this century sends. -//! -//! TWO FACTS A READER MUST NOT GET WRONG, because both are silent when wrong: -//! -//! 1. `size[4]` INCLUDES ITSELF. `convS2M.c:216-224` computes `size` from -//! `sizeS2M`, whose first line is `n += BIT32SZ; /* size */`, and then -//! writes that number into the first four bytes. A reader that treats it -//! as a payload length is four bytes out of step on every message and -//! resynchronises never. -//! -//! 2. A `stat` HAS TWO LENGTHS IN FRONT OF IT. The record itself begins with -//! `size[2]` which counts everything AFTER itself — `convD2M.c:48-51`, -//! «note that length excludes count field itself», `PBIT16(p, ss-BIT16SZ)` -//! — and `Rstat`/`Twstat` then wrap the whole record in ANOTHER `[2]` -//! count, which is why Linux reads `Rstat` with the format string `"wS"` -//! and throws the first `w` away into a variable literally named `ignored` -//! (`linux/net/9p/client.c:1617,1633`), and writes `Twstat` as `"dwS"` -//! (`client.c:1776`). So the outer count is `Stat.size() + 2`, never -//! `Stat.size()`. `Stat` below owns both numbers and the test -//! "9p: the stat double length" is the one that would catch it. -//! -//! FREESTANDING. No libc, no OS, no allocator, no threads: this file imports -//! `std` for `mem.readInt`/`writeInt` and `debug.assert` and nothing more, so -//! it compiles for `wasm32-freestanding` and for the board's -//! `riscv32-freestanding` exactly as `wire.zig` does. Every integer on the wire -//! has an explicit width and is little-endian; no `usize` reaches it, and no -//! Zig struct is ever `@bitCast` onto it. Decoding BORROWS: every `[]const u8` -//! in a decoded `Msg` points into the caller's buffer, which stays alive until -//! the reply is written. -//! -//! MALFORMED INPUT IS REFUSED. This parser is fed by a socket, and a message -//! misread rather than refused is an out-of-bounds index. Nothing below indexes -//! without first checking; `Error` names every way a stream can be wrong. -//! -//! THE SERVER HALF IS APPENDED TO THIS FILE, the way `fuse.zig` keeps its wire -//! structs and its transport together: a fid table, the dispatch onto -//! `acmefs.zig`'s nine operations, and the msize handshake. Codec first, then -//! the seam. Keeping them in one file is what makes it possible to change a -//! layout and its only caller in one diff. -//! -//! Verified against `u9fs` (`fcall.h`, `convS2M.c`, `convM2S.c`, `convD2M.c`, -//! `convM2D.c`), `linux/net/9p/{protocol,client}.c`, and -//! `ad/crates/ninep/src/sansio/protocol.rs`. const std = @import("std"); const assert = std.debug.assert; pub const Error = error{ - /// The message ended inside a field, or `size` claims more bytes than the - /// caller handed over. Both are "not all of it has arrived", which is what - /// a stream reader wants to hear: buffer more and ask again. Truncated, - /// A count larger than this protocol admits: `nwname > MAXWELEM`, a string - /// past 64 KiB, a `stat` past 64 KiB. Refused before anything is indexed. Overlong, - /// A type byte 9P2000 does not define, or defines as illegal (`Terror`). BadTag, - /// A field carrying a value it cannot mean — a `size` smaller than a - /// header, which is a number no encoder can have produced. BadValue, - /// The message was decoded and bytes were left over, either inside `size` - /// or after it. A message that says more than its layout has room for is - /// not this message. Trailing, - /// The encoder ran out of caller-supplied buffer. Nothing was written. NoSpace, }; -// --------------------------------------------------------------------------- -// message types -// --------------------------------------------------------------------------- - -/// The type byte. Numbers from `u9fs/fcall.h:74-105`, which is the definitive -/// list: `Tversion = 100` and every name after it takes the next value, so the -/// gap at 106 is load-bearing and the enum below spells it rather than skipping -/// it silently. -/// -/// Non-exhaustive for the same reason `fuse.zig`'s `Opcode` is: `@enumFromInt` -/// of an unlisted value into an exhaustive enum is undefined behaviour, which -/// is the one bug in a protocol decoder that cannot be diagnosed from outside. -/// A `.u` or `.L` client will hand us `Tstatfs = 8` or `Tlopen = 12`; that must -/// arrive as a value we can refuse (`decode` returns `error.BadTag`) rather -/// than as UB. -/// -/// TWENTY-SEVEN REAL TYPES: thirteen T/R pairs, plus `Rerror`, which is a reply -/// with no request. `Terror = 106` is the twenty-eighth number and is defined -/// as illegal by the protocol — a client cannot ask for an error — so it is -/// listed to keep the numbering honest and refused by name in `decode`. pub const Type = enum(u8) { tversion = 100, rversion = 101, @@ -122,8 +17,6 @@ pub const Type = enum(u8) { rauth = 103, tattach = 104, rattach = 105, - /// «Terror = 106, /* illegal */» — `fcall.h:82`. Never sent, never - /// accepted; here so that nobody re-derives 107 for `Rerror` by counting. terror = 106, rerror = 107, tflush = 108, @@ -149,112 +42,48 @@ pub const Type = enum(u8) { _, }; -/// T-messages are EVEN, R-messages are ODD, all the way from `Tversion = 100` -/// to `Rwstat = 127` (`fcall.h:74-105`), because the enum assigns each T an -/// even number and lets its R take the next. So one byte tells a reader which -/// direction a message is travelling, which makes a stream carrying both -/// SELF-DEMUXING: `drawterm`'s single descriptor has requests going one way and -/// replies coming back on it, and the parity alone separates them. -/// -/// PaRDeS does not rely on that today — a connection has one role per side -/// (docs/9p.typ §"Layering"), so a server only ever reads T and a client only -/// ever reads R, and each refuses the other by name. This is here because the -/// ENCODING GUARANTEES it and a future 9P-inside-the-wire arrangement over the -/// board's UART would want it, and because a hand-typed number that breaks the -/// parity is a bug the test at the bottom catches for free. pub fn isT(t: Type) bool { return @intFromEnum(t) % 2 == 0; } -// --------------------------------------------------------------------------- -// constants -// --------------------------------------------------------------------------- - -/// `size[4] type[1] tag[2]`, and `size` counts these seven bytes too. Public -/// because the server half sizes its reply payloads against it: the largest -/// `Rread` that fits an msize is `msize - header_len - 4`. pub const header_len: usize = 4 + 1 + 2; -/// `QIDSZ` — `fcall.h:64`, `BIT8SZ+BIT32SZ+BIT64SZ`. pub const qid_len: usize = 1 + 4 + 8; -/// `STATFIXLEN` — `fcall.h:66-68`. The fixed part of a `stat` INCLUDING its own -/// leading `size[2]` and the four string count prefixes, excluding the string -/// bytes. `BIT16SZ + QIDSZ + 5*BIT16SZ + 4*BIT32SZ + BIT64SZ` = 49. pub const stat_fixed: usize = 2 + qid_len + 5 * 2 + 4 * 4 + 8; -/// `NOTAG` — `fcall.h:71`. The tag on `Tversion`/`Rversion`, which is the one -/// exchange that happens before tags mean anything. Note that `fcall.h` writes -/// it `~0U` and the wire field is two bytes, so it is 0xFFFF and not 0xFFFFFFFF. pub const notag: u16 = 0xFFFF; -/// `NOFID` — `fcall.h:121-123`. `Tattach.afid` when no authentication fid was -/// established, which is our only use of it: we serve `Tauth` a refusal. pub const nofid: u32 = 0xFFFF_FFFF; -/// `MAXWELEM` — `fcall.h:2`. The most path elements one `Twalk` may carry, and -/// a hard protocol bound rather than a buffer size: `convS2M.c:279-280` and -/// `convM2S.c:170-171` both return failure above it, so a 17-element walk is -/// refused by every implementation and must be split by the client. pub const max_welem: usize = 16; -/// The smallest msize we may agree to. NOT from the protocol — 9P has no floor, -/// and Plan 9's devmnt, plan9port's `9p` and our own client all accept 512 -/// (docs/registry.typ, `linux/net/9p/client.c:840-843`). This number exists -/// because the LINUX KERNEL refuses to mount below it, and a mount that fails -/// with `EINVAL` and no message is the worst diagnostic in the set. pub const min_msize: u32 = 4096; -/// `IOHDRSZ` — `fcall.h:72`, «ample room for Twrite/Rread header (iounit)». -/// The real `Rread` header is 11 bytes (`size[4] type[1] tag[2] count[4]`) and -/// `Twrite`'s is 23; 24 is the slack both ends have agreed to reserve for -/// thirty years, and `iounit` is quoted to clients as `msize - iohdrsz`. pub const iohdrsz: u32 = 24; -/// `ERRMAX` — Plan 9's `libc.h:146`. The buffer a Plan 9 client has for an -/// error string. ADVISORY here: `decode` does not refuse a longer `Rerror`, -/// because refusing a peer's error message is the least useful moment to -/// discover a length limit. The server half truncates its own to this. pub const errmax: usize = 128; -// Qid type bits — `u9fs/plan9.h:156-161`, cross-checked against -// `linux/include/net/9p/9p.h:344-352` which adds QTTMP = 0x04. These are the -// top five bits of `Stat.mode` shifted down 24; see `dmdir` below. pub const qtdir: u8 = 0x80; pub const qtappend: u8 = 0x40; pub const qtexcl: u8 = 0x20; -/// 0x10 is `QTMOUNT`, a mounted channel — a thing only a Plan 9 kernel has, and -/// the reason the mode bits below have a gap at bit 28. pub const qtmount: u8 = 0x10; pub const qtauth: u8 = 0x08; pub const qttmp: u8 = 0x04; -/// «plain file» — `plan9.h:161`. Zero, so a `Qid.type` of 0 is not "unset". pub const qtfile: u8 = 0x00; -// Mode bits — `u9fs/plan9.h:164-170`, with DMAUTH and DMTMP from -// `ad/crates/ninep/src/sansio/protocol.rs:486-495`: «bit 27 (DMAUTH) ... bit 26 -// (DMTMP) ... (Bit 28 is skipped for historical reasons)». That skipped bit is -// `DMMOUNT`, which is why the top five type bits are not the top five mode -// bits: they are DMDIR, DMAPPEND, DMEXCL, (gap), DMAUTH, DMTMP reproduced from -// the top down into `Qid.type` as QTDIR, QTAPPEND, QTEXCL, QTAUTH, QTTMP. pub const dmdir: u32 = 0x8000_0000; pub const dmappend: u32 = 0x4000_0000; pub const dmexcl: u32 = 0x2000_0000; pub const dmmount: u32 = 0x1000_0000; pub const dmauth: u32 = 0x0800_0000; pub const dmtmp: u32 = 0x0400_0000; -/// The rwx triples, and the ONLY part of `mode` that is a Unix permission. A -/// server that hands the high bits to `chmod`, or a client that hands the low -/// nine to a type test, has confused the two halves of one word. pub const dmperm: u32 = 0o777; comptime { - // The three widths every offset below is derived from. A drifted number - // here is a codec that agrees with nothing, so make it a compile error. assert(header_len == 7); assert(qid_len == 13); assert(stat_fixed == 49); - // Parity is the protocol's, not a convention we maintain by hand. for (std.enums.values(Type)) |t| { const even = @intFromEnum(t) % 2 == 0; assert(isT(t) == even); @@ -262,23 +91,11 @@ comptime { } } -// --------------------------------------------------------------------------- -// qid -// --------------------------------------------------------------------------- - -/// The server's name for a file: `type[1] version[4] path[8]`, thirteen bytes, -/// `convS2M.c:18-29`. Two files are the same file if and only if their qids -/// are equal, which is the whole contract — `path` identifies the file for the -/// life of the connection and `version` changes on every write, so a client -/// caches against the pair and never against a pathname. pub const Qid = struct { - /// `qt*` bits. The high bits of `Stat.mode` shifted down 24. type: u8, version: u32, path: u64, - /// Writes thirteen bytes and returns them. Takes the whole buffer and - /// returns the used slice, so a caller can chain without arithmetic. pub fn encode(self: Qid, buf: []u8) Error![]u8 { if (buf.len < qid_len) return error.NoSpace; buf[0] = self.type; @@ -287,8 +104,6 @@ pub const Qid = struct { return buf[0..qid_len]; } - /// Reads thirteen bytes. Refuses a shorter buffer rather than reading one: - /// `gqid` in `convM2S.c:26-37` returns nil for exactly this case. pub fn decode(bytes: []const u8) Error!Qid { if (bytes.len < qid_len) return error.Truncated; return .{ @@ -299,30 +114,6 @@ pub const Qid = struct { } }; -// --------------------------------------------------------------------------- -// stat -// --------------------------------------------------------------------------- - -/// One directory entry, and the payload of `Rstat` and `Twstat`. Layout from -/// `convD2M.c:56-83`: -/// -/// ``` -/// size[2] type[2] dev[4] qid[13] mode[4] atime[4] mtime[4] length[8] -/// name[s] uid[s] gid[s] muid[s] -/// ``` -/// -/// where `[s]` is `n[2]` plus n bytes of UTF-8, NOT NUL-terminated. `size` -/// counts everything after itself, so the record occupies `size() + 2` bytes; -/// see the module header for why that matters twice over. -/// -/// `type` and `dev` are Plan 9 kernel device identifiers and are meaningless -/// off Plan 9 — u9fs sends zeros and so do we, but they are on the wire because -/// the layout is fixed. `muid` is the uid of the last modifier; for a synthetic -/// tree it is whoever attached. -/// -/// A `Twstat` uses the sentinel "don't touch" values that acme(4) and -/// `stat(5)` specify: an empty string, an all-ones integer. Nothing here -/// interprets them; that is the server half's job. pub const Stat = struct { type: u16, dev: u32, @@ -336,14 +127,6 @@ pub const Stat = struct { gid: []const u8, muid: []const u8, - /// The value that goes in the leading `size[2]`: every byte of the record - /// EXCEPT those two. `convD2M.c:46-50` computes `ss = STATFIXLEN + ns` and - /// then writes `ss - BIT16SZ`, so this is `stat_fixed - 2` plus the four - /// string bodies. Written out field by field rather than as 47, because a - /// number nobody can check against a layout is a comment that rots. - /// - /// Fallible: the prefix is two bytes, so a record whose strings do not fit - /// a u16 has no legal encoding and must be refused rather than wrapped. pub fn size(self: Stat) Error!u16 { const n = 2 + // type @@ -362,9 +145,6 @@ pub const Stat = struct { return @intCast(n); } - /// Writes `size[2]` and the record, and returns the `size() + 2` bytes of - /// it. `assert` at the end is `convD2M.c:85-86`'s `if(ss != p - buf)`: the - /// two arithmetics are written separately and must agree. pub fn encode(self: Stat, buf: []u8) Error![]u8 { const n = try self.size(); const total = @as(usize, n) + 2; @@ -386,15 +166,6 @@ pub const Stat = struct { return buf[0..total]; } - /// Decodes exactly one record from `bytes`, which must be the whole of it — - /// prefix included — and nothing more. The strings BORROW from `bytes`. - /// - /// The equality check on the prefix is the second half of `statcheck` - /// (`convM2D.c:14-22`: walk the four counts, then `if(buf != ebuf) return - /// -1`). It is what makes the double length safe: the caller has already - /// bounded `bytes` by the OUTER count, so demanding that the INNER count - /// agree refuses the classic off-by-two in both directions instead of - /// trusting whichever one the sender got right. pub fn decode(bytes: []const u8) Error!Stat { var r: Reader = .init(bytes); const n = try r.getU16(); @@ -419,95 +190,42 @@ pub const Stat = struct { } }; -// --------------------------------------------------------------------------- -// messages -// --------------------------------------------------------------------------- - -/// Every message base 9P2000 defines, with the fields it actually carries. -/// Layouts from `convS2M.c:231-419` and `convM2S.c:73-375`, which are the two -/// halves of the same table and disagree nowhere. -/// -/// The tag names are the type names, so `msgType` is a mechanical mapping and -/// not a table somebody maintains; a variant added here without a `Type` is a -/// compile error. -/// -/// NOT IN HERE: the message tag. A `Msg` is a message's CONTENT, and the tag is -/// the transport's matching of a reply to a request — it is a parameter of -/// `encode` and a field of `Decoded`. Putting it in the union would mean every -/// server handler that builds a reply has to remember to copy it. -/// -/// `Twalk` is the large variant at sixteen slices, so `Msg` is around 280 bytes -/// on a 64-bit host. That is a value passed by const pointer in practice and it -/// buys the thing that matters: a walk decodes with no allocator and no bound -/// the caller has to have guessed. pub const Msg = union(enum) { - /// The first exchange, tagged `notag`. `msize` is the largest message - /// either end will send, INCLUDING the seven-byte header; `version` is - /// "9P2000" or a string starting with it. tversion: struct { msize: u32, version: []const u8 }, - /// The server's answer: `msize` no larger than the client's, and `version` - /// either "9P2000" or the literal "unknown" — which is a successful reply - /// meaning "no dialect in common", not an `Rerror`. rversion: struct { msize: u32, version: []const u8 }, tauth: struct { afid: u32, uname: []const u8, aname: []const u8 }, - /// `aqid` and not `qid`: `fcall.h:44` gives `Rauth` its own field, and - /// `convS2M.c:368-370` writes it. Same thirteen bytes, different meaning — - /// the qid of the auth FILE, not of the tree. rauth: struct { aqid: Qid }, - /// `afid` is `nofid` when the client did not authenticate. tattach: struct { fid: u32, afid: u32, uname: []const u8, aname: []const u8 }, rattach: struct { qid: Qid }, - /// A STRING and nothing else. Base 9P2000 has no numeric error code; the - /// `errno` field is 9P2000.u's, which this file does not serve. See the - /// module header. rerror: struct { ename: []const u8 }, - /// `oldtag` is a u16 like every tag, even though `fcall.h:14` declares - /// `oldtag` as u32 — `convS2M.c:251-253` writes it with `PBIT16`. tflush: struct { oldtag: u16 }, rflush: void, - /// `wname[0..nwname]` are the path elements; anything past `nwname` is - /// undefined and neither encoded nor compared. A zero-element walk is - /// legal and means "clone `fid` into `newfid`". twalk: struct { fid: u32, newfid: u32, nwname: u16, wname: [max_welem][]const u8 = @splat(""), }, - /// `nwqid` may be SHORTER than the request's `nwname`: a partial walk is a - /// successful `Rwalk` with fewer qids, and only a failure on the FIRST - /// element is an `Rerror`. rwalk: struct { nwqid: u16, wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }), }, - /// `mode` is OREAD/OWRITE/ORDWR/OEXEC plus OTRUNC/ORCLOSE, one byte. topen: struct { fid: u32, mode: u8 }, - /// `iounit`: the largest atomic read or write, or 0 for "no promise". We - /// quote `msize - iohdrsz`. ropen: struct { qid: Qid, iounit: u32 }, - /// `perm` is the full mode word — `dmdir` and friends in the high bits, - /// `dmperm` in the low nine. tcreate: struct { fid: u32, name: []const u8, perm: u32, mode: u8 }, rcreate: struct { qid: Qid, iounit: u32 }, tread: struct { fid: u32, offset: u64, count: u32 }, - /// `count[4]` then the bytes, held as one slice because the count is the - /// slice's length and two ways to say one number is one way to disagree. - /// A reply longer than the request's `count` is a hard `-EIO` to Linux - /// (`net/9p/client.c:1475-1479`), so the server half clamps and this codec - /// carries whatever it is given. rread: struct { data: []const u8 }, twrite: struct { fid: u32, offset: u64, data: []const u8 }, - /// The count actually written, which may be short. rwrite: struct { count: u32 }, tclunk: struct { fid: u32 }, @@ -516,17 +234,10 @@ pub const Msg = union(enum) { rremove: void, tstat: struct { fid: u32 }, - /// Carries a decoded `Stat`, not a blob, so the double length is computed - /// in one place — `encode` derives the outer count from `stat.size() + 2` - /// and `decode` demands they agree. u9fs keeps `nstat` and a `uchar*` here - /// (`fcall.h:40-41`) and pays for it with `statcheck` as a separate call - /// every caller must remember. rstat: struct { stat: Stat }, twstat: struct { fid: u32, stat: Stat }, rwstat: void, - /// The type byte this message travels as. Mechanical, by name, so a - /// variant cannot acquire the wrong number. pub fn msgType(msg: Msg) Type { return switch (msg) { inline else => |_, t| @field(Type, @tagName(t)), @@ -534,35 +245,16 @@ pub const Msg = union(enum) { } }; -/// One complete message off the wire: its tag and its content. The tag is -/// separate for the reason `Msg`'s doc gives — a reply reuses the request's tag -/// and never looks inside it. pub const Decoded = struct { tag: u16, msg: Msg, }; -/// How many bytes this message will be, once the caller has enough of it. -/// `null` when there are fewer than four, which is the only answer a stream -/// reader can act on: read more. -/// -/// Deliberately UNVALIDATED. It is the raw `size` field, and it is peeked -/// before the type byte has necessarily arrived, so there is nothing here to -/// check it against. `decode` does the refusing; this only says how much to -/// buffer, and a caller that compares the answer to its negotiated msize -/// refuses an absurd claim before growing anything. pub fn frameLen(prefix: []const u8) ?u32 { if (prefix.len < 4) return null; return std.mem.readInt(u32, prefix[0..4], .little); } -/// The whole message's byte count, `size` included, which IS the value of the -/// `size` field. `sizeS2M` in `convS2M.c:38-208`, in the same order, so the two -/// can be read side by side. -/// -/// Computed before a single byte is written, which is what makes `encode`'s -/// `NoSpace` clean: a caller whose buffer is one byte short gets an error and -/// an untouched buffer, not a half-written message. fn totalLen(msg: Msg) Error!usize { const body: usize = switch (msg) { .tversion => |m| 4 + try stringLen(m.version), @@ -575,9 +267,6 @@ fn totalLen(msg: Msg) Error!usize { .tflush => 2, .rflush => 0, .twalk => |m| blk: { - // The bound is the protocol's, and both halves of u9fs return - // failure above it (`convS2M.c:279`, `convM2S.c:170`). On this side - // it is a caller bug — the array is sixteen long — so it asserts. assert(m.nwname <= max_welem); var n: usize = 4 + 4 + 2; for (m.wname[0..m.nwname]) |name| n += try stringLen(name); @@ -600,8 +289,6 @@ fn totalLen(msg: Msg) Error!usize { .tremove => 4, .rremove => 0, .tstat => 4, - // THE DOUBLE LENGTH, in the one place it is computed: the outer count, - // then the record, whose own prefix is inside `size() + 2`. .rstat => |m| 2 + 2 + @as(usize, try m.stat.size()), .twstat => |m| 4 + 2 + 2 + @as(usize, try m.stat.size()), .rwstat => 0, @@ -611,32 +298,20 @@ fn totalLen(msg: Msg) Error!usize { return total; } -/// `stringsz` — `convS2M.c:31-36`. The count is two bytes, so a longer string -/// has no encoding and is refused here rather than truncated silently. fn stringLen(s: []const u8) Error!usize { if (s.len > std.math.maxInt(u16)) return error.Overlong; return 2 + s.len; } -/// The same for a `count[4]` payload: `Rread`'s and `Twrite`'s data. fn dataLen(d: []const u8) Error!usize { if (d.len > std.math.maxInt(u32)) return error.Overlong; return 4 + d.len; } -/// Encodes one message into `buf` and returns the bytes of it, which start at -/// `buf[0]` and are exactly `size` long. -/// -/// `tag` is a parameter and not a field of `Msg`: a server handler builds a -/// reply and the transport supplies the request's tag, so the two cannot drift. -/// `notag` on anything but `Tversion`/`Rversion` is the caller's business. pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { const total = try totalLen(msg); if (total > buf.len) return error.NoSpace; - // The writer is bounded to `total` and not to `buf`, so a disagreement - // between `totalLen` and the field walk below cannot scribble past the - // message — it becomes `NoSpace` here or the assert at the end. var w: Writer = .init(buf[0..total]); try w.putU32(@intCast(total)); try w.putByte(@intFromEnum(msg.msgType())); @@ -717,7 +392,6 @@ pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { .rremove => {}, .tstat => |m| try w.putU32(m.fid), .rstat => |m| { - // Outer count first: the whole record, its own prefix included. try w.putU16(try m.stat.size() + 2); try w.putStat(m.stat); }, @@ -729,30 +403,13 @@ pub fn encode(msg: Msg, tag: u16, buf: []u8) Error![]u8 { .rwstat => {}, } - // `convS2M.c:420-421`: `if(size != p-ap) return 0`. The two arithmetics are - // deliberately separate and this is the only thing that keeps them honest. assert(w.n == total); return buf[0..total]; } -/// Decodes exactly one complete message. `bytes` must be the message and -/// nothing else — `frameLen` is how a reader knows where that ends — and every -/// slice in the result BORROWS from it. -/// -/// Four ways this refuses, in the order the checks run, because the order is -/// what makes a stream reader's life simple: -/// * fewer than seven bytes, or `size` past the end -> `Truncated`, meaning -/// "come back with more". -/// * `size` short of the end -> `Trailing`. Two messages were handed over as -/// one, which is a framing bug in the caller, not a short read. -/// * a `size` that cannot hold a header -> `BadValue`. No encoder produced it. -/// * a type byte 9P2000 does not define, or defines illegal -> `BadTag`. pub fn decode(bytes: []const u8) Error!Decoded { if (bytes.len < header_len) return error.Truncated; const size = std.mem.readInt(u32, bytes[0..4], .little); - // `convM2S.c:65-66` refuses this too, and it must be refused BEFORE the - // comparison against `bytes.len`: a size of 3 on a 3-byte buffer would - // otherwise slice a header out of nothing. if (size < header_len) return error.BadValue; if (size > bytes.len) return error.Truncated; if (size < bytes.len) return error.Trailing; @@ -760,9 +417,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { const t: Type = @enumFromInt(bytes[4]); const tag = std.mem.readInt(u16, bytes[5..7], .little); - // Bounded by `size` and not by `bytes`, which is the same thing here only - // because of the two checks above; keep it explicit so it stays true if a - // caller is ever allowed to pass a longer buffer. var r: Reader = .init(bytes[header_len..size]); const msg: Msg = switch (t) { @@ -790,8 +444,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { .newfid = try r.getU32(), .nwname = try r.getU16(), } }; - // Checked before the loop, so a hostile 65535 never reaches the - // array. `convM2S.c:170-171` does the same and for the same reason. if (m.twalk.nwname > max_welem) return error.Overlong; for (m.twalk.wname[0..m.twalk.nwname]) |*name| name.* = try r.getString(); break :blk m; @@ -834,10 +486,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { .stat = try Stat.decode(try r.getBlob16()), } }, .rwstat => .rwstat, - // «Terror = 106, /* illegal */». A peer that sent one is not speaking - // 9P2000, and every other unlisted byte is a `.u`/`.L` message or - // noise. Both are refused here, which is also why `Type` is - // non-exhaustive: this switch is reachable with any byte. .terror, _ => return error.BadTag, }; @@ -845,15 +493,6 @@ pub fn decode(bytes: []const u8) Error!Decoded { return .{ .tag = tag, .msg = msg }; } -// --------------------------------------------------------------------------- -// primitives -// --------------------------------------------------------------------------- -// -// Explicit widths, little-endian, one field at a time. `GBIT*`/`PBIT*` in -// `fcall.h:48-58` are the reference, and they are byte-at-a-time shifts for -// exactly the reason this file does not blit a struct: the sender's word order -// and padding are not the protocol. - const Writer = struct { buf: []u8, n: usize = 0, @@ -862,8 +501,6 @@ const Writer = struct { return .{ .buf = buf }; } - /// `n <= buf.len` is the invariant every putter preserves, which is what - /// makes the subtraction safe. fn room(w: *Writer, k: usize) Error![]u8 { if (w.buf.len - w.n < k) return error.NoSpace; defer w.n += k; @@ -890,10 +527,6 @@ const Writer = struct { @memcpy(try w.room(v.len), v); } - /// `n[2]` then the bytes, NOT NUL-terminated — `pstring`, `convS2M.c:4-16`. - /// The length was already refused by `stringLen` before anything was - /// written, so this asserts rather than erroring: reaching it with a longer - /// string means `totalLen` and this switch disagree. fn putString(w: *Writer, v: []const u8) Error!void { assert(v.len <= std.math.maxInt(u16)); try w.putU16(@intCast(v.len)); @@ -918,8 +551,6 @@ const Reader = struct { return .{ .bytes = bytes }; } - /// The one place this file indexes, and the one place it can refuse to. - /// `i <= bytes.len` always, so the subtraction cannot wrap. fn take(r: *Reader, n: usize) Error![]const u8 { if (r.bytes.len - r.i < n) return error.Truncated; defer r.i += n; @@ -942,25 +573,14 @@ const Reader = struct { return std.mem.readInt(u64, (try r.take(8))[0..8], .little); } - /// `gstring`, `convM2S.c:4-22`, minus the memmove: u9fs shuffles the bytes - /// down over the count to make room for a '\0' because its callers are C - /// string functions. Ours borrow, so the slice IS the string and the buffer - /// is untouched. fn getString(r: *Reader) Error![]const u8 { return r.take(try r.getU16()); } - /// `count[4]` then the bytes: `Rread`'s and `Twrite`'s payload. A count - /// past the message is `Truncated` and not a clamp — Linux clamps here - /// (`protocol.c:386-388`) and then has to catch the lie again in - /// `client.c:1475-1479`. Refusing once is cheaper and says more. fn getData(r: *Reader) Error![]const u8 { return r.take(try r.getU32()); } - /// `count[2]` then the bytes: the OUTER count of an `Rstat`/`Twstat` stat. - /// Slicing exactly here is what lets `Stat.decode` insist that the record's - /// own prefix agrees, which is the whole defence against the double length. fn getBlob16(r: *Reader) Error![]const u8 { return r.take(try r.getU16()); } @@ -974,29 +594,10 @@ const Reader = struct { } }; -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// Three obligations, and the third is the one that is usually skipped. -// -// 1. every message round-trips to an equal value, because a hand-written -// codec is a codec whose two halves drift; -// 2. every malformed shape is REFUSED and none of them panics, because this -// parser is fed by a socket; -// 3. the bytes are the RIGHT bytes. A round-trip test proves the encoder and -// the decoder agree with each other and nothing about whether they agree -// with plan9port's `9p`, which is who will actually be on the far end. So -// four messages are hand-verified against `u9fs/convS2M.c` as literal -// arrays with the line numbers attached. - const testing = std.testing; fn roundTrip(buf: []u8, tag: u16, msg: Msg) !Msg { const bytes = try encode(msg, tag, buf); - // The framing has to agree with the encoder before anything else is worth - // checking: `size` includes itself, so this is also the regression test for - // the first of the module header's two facts. try testing.expectEqual(bytes.len, frameLen(bytes).?); const got = try decode(bytes); try testing.expectEqual(tag, got.tag); @@ -1019,9 +620,6 @@ fn expectStatEqual(want: Stat, have: Stat) !void { try testing.expectEqualStrings(want.muid, have.muid); } -/// Field by field, because `std.meta.eql` is wrong here twice: a decoded slice -/// points into the wire buffer and never compares equal by pointer, and -/// `Twalk.wname` past `nwname` is scratch the decoder does not invent. fn expectMsgEqual(want: Msg, have: Msg) !void { switch (want) { .tversion => |w| { @@ -1118,16 +716,12 @@ const sample_stat: Stat = .{ }; test "9p: the type numbers and their parity are the protocol's own" { - // Copied from `u9fs/fcall.h:74-105`. Asserted as literals because a - // renumbering here is a codec that talks to nothing, and it must be a diff - // somebody reads rather than a silent change. try testing.expectEqual(@as(u8, 100), @intFromEnum(Type.tversion)); try testing.expectEqual(@as(u8, 106), @intFromEnum(Type.terror)); try testing.expectEqual(@as(u8, 107), @intFromEnum(Type.rerror)); try testing.expectEqual(@as(u8, 126), @intFromEnum(Type.twstat)); try testing.expectEqual(@as(u8, 127), @intFromEnum(Type.rwstat)); - // Twenty-eight numbers, 100..127 inclusive, no gaps and no strays. try testing.expectEqual(@as(usize, 28), std.enums.values(Type).len); for (std.enums.values(Type), 100..) |t, want| try testing.expectEqual(@as(u8, @intCast(want)), @intFromEnum(t)); @@ -1136,7 +730,6 @@ test "9p: the type numbers and their parity are the protocol's own" { try testing.expect(isT(.twstat)); try testing.expect(!isT(.rwstat)); - // `notag` is two bytes wide even though `fcall.h:71` writes `~0U`. try testing.expectEqual(@as(u16, 0xFFFF), notag); try testing.expectEqual(@as(u32, 0xFFFF_FFFF), nofid); try testing.expectEqual(@as(usize, 16), max_welem); @@ -1148,8 +741,6 @@ test "9p: a qid is thirteen bytes" { try testing.expectEqual(qid_len, bytes.len); try testing.expectEqual(@as(usize, 13), bytes.len); try testing.expectEqual(sample_qid, try Qid.decode(bytes)); - // Twelve bytes is not a qid, and a decoder that read one anyway would be - // reading the next field's first byte as the top of `path`. try testing.expectError(error.Truncated, Qid.decode(bytes[0..12])); try testing.expectError(error.NoSpace, sample_qid.encode(buf[0..12])); } @@ -1159,14 +750,11 @@ test "9p: an encoded stat is size() + 2 bytes" { const bytes = try sample_stat.encode(&buf); const n = try sample_stat.size(); try testing.expectEqual(@as(usize, n) + 2, bytes.len); - // `STATFIXLEN - BIT16SZ` plus the four string bodies: 47 + 4 + 6 + 6 + 6. try testing.expectEqual(@as(u16, 69), n); try testing.expectEqual(stat_fixed - 2 + 22, n); - // The prefix on the wire is the count EXCLUDING itself — `convD2M.c:48-51`. try testing.expectEqual(n, std.mem.readInt(u16, bytes[0..2], .little)); try expectStatEqual(sample_stat, try Stat.decode(bytes)); - // Empty strings still cost their counts: 47 and nothing more. const bare: Stat = .{ .type = 0, .dev = 0, @@ -1189,9 +777,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = 8192, .version = "9P2000" } }); - // "unknown" is a SUCCESSFUL Rversion meaning no dialect in common, and the - // codec must carry it like any other string rather than treat it as an - // error path. _ = try roundTrip(&buf, notag, .{ .rversion = .{ .msize = min_msize, .version = "unknown" } }); _ = try roundTrip(&buf, 1, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } }); _ = try roundTrip(&buf, 1, .{ .rauth = .{ .aqid = .{ .type = qtauth, .version = 0, .path = 9 } } }); @@ -1208,8 +793,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, 7, .{ .rcreate = .{ .qid = sample_qid, .iounit = 0 } }); _ = try roundTrip(&buf, 8, .{ .tread = .{ .fid = 1, .offset = 0xdead_beef_cafe, .count = 4096 } }); _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "hello" } }); - // A zero-byte Rread is end of file and not an error, which is exactly what - // docs/9p.typ promises a pty reader on exit. _ = try roundTrip(&buf, 8, .{ .rread = .{ .data = "" } }); _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "Edit ,d" } }); _ = try roundTrip(&buf, 9, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "" } }); @@ -1223,9 +806,6 @@ test "9p: every message round-trips" { _ = try roundTrip(&buf, 13, .{ .twstat = .{ .fid = 1, .stat = sample_stat } }); _ = try roundTrip(&buf, 13, .rwstat); - // Every type that has a message got one. The count is the thirteen pairs - // plus Rerror; `Terror` is illegal and has no variant, which is what the - // arithmetic below is really asserting. try testing.expectEqual(@as(usize, 27), @typeInfo(Msg).@"union".fields.len); try testing.expectEqual(std.enums.values(Type).len - 1, @typeInfo(Msg).@"union".fields.len); } @@ -1233,12 +813,10 @@ test "9p: every message round-trips" { test "9p: empty and maximum-length strings survive the trip" { var buf: [70_000]u8 = undefined; - // Empty is not absent: the count is still two bytes. const empty = try roundTrip(&buf, 1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "", .aname = "" } }); try testing.expectEqual(@as(usize, 0), empty.tattach.uname.len); try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 2), (try encode(empty, 1, &buf)).len); - // The largest string a `n[2]` count can describe, and the one past it. var big: [65_536]u8 = undefined; @memset(&big, 'x'); const max = big[0..std.math.maxInt(u16)]; @@ -1246,8 +824,6 @@ test "9p: empty and maximum-length strings survive the trip" { try testing.expectEqual(@as(usize, 65_535), got.rerror.ename.len); try testing.expectError(error.Overlong, encode(.{ .rerror = .{ .ename = &big } }, 1, &buf)); - // ...and a stat whose strings overflow its own two-byte prefix. Refused by - // `size()`, which is the only place that arithmetic happens. var wide = sample_stat; wide.name = max; try testing.expectError(error.Overlong, wide.size()); @@ -1257,7 +833,6 @@ test "9p: empty and maximum-length strings survive the trip" { test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { var buf: [512]u8 = undefined; - // Zero elements is a legal walk and means "clone the fid". const zero = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } }); try testing.expectEqual(@as(u16, 0), zero.twalk.nwname); try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2), (try encode(zero, 1, &buf)).len); @@ -1269,17 +844,12 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { .wname = .{"body"} ++ @as([max_welem - 1][]const u8, @splat("")), } }); - // MAXWELEM exactly, all distinct so a swapped index cannot pass. const names: [max_welem][]const u8 = .{ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p" }; const full = try roundTrip(&buf, 1, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = max_welem, .wname = names } }); try testing.expectEqual(@as(u16, 16), full.twalk.nwname); for (names, full.twalk.wname[0..max_welem]) |a, b| try testing.expectEqualStrings(a, b); - // Rwalk's bound is the same and its own. _ = try roundTrip(&buf, 1, .{ .rwalk = .{ .nwqid = max_welem, .wqid = @splat(sample_qid) } }); - // Seventeen. Hand-built, because the encoder's array cannot hold one — the - // point is that a PEER can send it and must be refused before the count - // reaches an array of sixteen. var raw: [256]u8 = undefined; const bad = blk: { var w: Writer = .init(&raw); @@ -1296,7 +866,6 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { try testing.expectEqual(@as(usize, header_len + 4 + 4 + 2 + 17 * 3), bad.len); try testing.expectError(error.Overlong, decode(bad)); - // Same for Rwalk: seventeen qids is 221 bytes of legal-looking message. const bad_r = blk: { var w: Writer = .init(&raw); try w.putU32(0); @@ -1313,9 +882,6 @@ test "9p: Twalk carries 0, 1 and 16 elements and refuses 17" { test "9p: the stat double length" { var buf: [512]u8 = undefined; - // THE fact. Rstat is `count[2]` then a record that begins with its own - // `size[2]`, and the outer number is the inner one plus two — - // `linux/net/9p/client.c:1633` reads it as "wS" and drops the first w. var good: [512]u8 = undefined; const n = blk: { const bytes = try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf); @@ -1327,29 +893,20 @@ test "9p: the stat double length" { try testing.expectEqual(inner, std.mem.readInt(u16, good[header_len + 2 ..][0..2], .little)); try testing.expectEqual(header_len + 2 + @as(usize, inner) + 2, n); - // Twstat wraps the same pair behind a fid — `client.c:1776`, "dwS". const w_bytes = try encode(.{ .twstat = .{ .fid = 7, .stat = sample_stat } }, 1, &buf); try testing.expectEqual(inner + 2, std.mem.readInt(u16, w_bytes[header_len + 4 ..][0..2], .little)); try testing.expectEqual(inner, std.mem.readInt(u16, w_bytes[header_len + 6 ..][0..2], .little)); - // Now three ways to get it wrong, which is the whole reason `Stat.decode` - // is handed an exact slice instead of a cursor. Each is two bytes of edit - // on a message that is otherwise perfect, and each is refused. var off: [512]u8 = undefined; - // THE CLASSIC: the outer count written without the +2, so the record's own - // prefix then claims two bytes more than the outer count allowed. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len..][0..2], inner, .little); try testing.expectError(error.Truncated, decode(off[0..n])); - // The outer count too large, which is the same mistake made twice. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len..][0..2], inner + 4, .little); try testing.expectError(error.Truncated, decode(off[0..n])); - // The INNER count wrong instead, in both directions: a record that claims - // more than the outer count fits, and one that leaves bytes over inside it. @memcpy(off[0..n], good[0..n]); std.mem.writeInt(u16, off[header_len + 2 ..][0..2], inner + 2, .little); try testing.expectError(error.Truncated, decode(off[0..n])); @@ -1359,14 +916,6 @@ test "9p: the stat double length" { try testing.expectError(error.Trailing, decode(off[0..n])); } -/// Every prefix of a complete message must be refused, in both of the two -/// shapes a short message arrives in: -/// * off a socket, where `size` still claims the whole thing and the header -/// check catches it; -/// * as a message that LIES about being complete, where `size` agrees with -/// the buffer and only the per-field walk can catch it. This is the one -/// that exercises every field boundary, and the one an attacker sends. -/// Neither may panic and neither may parse. fn expectTruncatedAtEveryBoundary(full: []const u8) !void { var scratch: [1024]u8 = undefined; var n: usize = 0; @@ -1377,46 +926,35 @@ fn expectTruncatedAtEveryBoundary(full: []const u8) !void { std.mem.writeInt(u32, scratch[0..4], @intCast(n), .little); try testing.expectError(error.Truncated, decode(scratch[0..n])); } - // The complete message, by contrast, is fine — otherwise the loop above - // would pass for a message that never decodes at all. _ = try decode(full); } test "9p: truncation at every field boundary is refused" { var buf: [512]u8 = undefined; - // Tversion: size, type, tag, msize, a count, a string. try expectTruncatedAtEveryBoundary(try encode( .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, notag, &buf, )); - // Twalk: two fids, a count, and then a loop of counted strings, which is - // the only variable-arity field in the protocol. try expectTruncatedAtEveryBoundary(try encode(.{ .twalk = .{ .fid = 1, .newfid = 2, .nwname = 3, .wname = .{ "usr", "", "bin" } ++ @as([max_welem - 3][]const u8, @splat("")), } }, 1, &buf)); - // Tread: the widest fixed body, and the one whose 8-byte offset a - // native-struct blit would misalign. try expectTruncatedAtEveryBoundary(try encode( .{ .tread = .{ .fid = 1, .offset = 0x0102_0304_0506_0708, .count = 8168 } }, 1, &buf, )); - // Rstat: both lengths, and every field of the record behind them. try expectTruncatedAtEveryBoundary(try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf)); - // Rread, whose count is a u32 and whose payload is the message's tail. try expectTruncatedAtEveryBoundary(try encode(.{ .rread = .{ .data = "12345678" } }, 1, &buf)); - // Rwalk, the other variable-arity body. try expectTruncatedAtEveryBoundary(try encode( .{ .rwalk = .{ .nwqid = 3, .wqid = @splat(sample_qid) } }, 1, &buf, )); - // Twstat: a fid in front of the double length. try expectTruncatedAtEveryBoundary(try encode(.{ .twstat = .{ .fid = 1, .stat = sample_stat } }, 1, &buf)); } @@ -1428,21 +966,14 @@ test "9p: a size field that disagrees with the buffer is refused" { var raw: [64]u8 = undefined; @memcpy(raw[0..bytes.len], bytes); - // Larger than the buffer: not all of it has arrived. Every value up to a - // hostile 4 GiB claim, which must not be believed for one instruction. for ([_]u32{ 12, 13, 64, 1 << 20, std.math.maxInt(u32) }) |claim| { std.mem.writeInt(u32, raw[0..4], claim, .little); try testing.expectError(error.Truncated, decode(raw[0..bytes.len])); } - // Smaller than the buffer: two messages handed over as one. The caller's - // framing is wrong, and silently decoding the first would hide it. std.mem.writeInt(u32, raw[0..4], 10, .little); try testing.expectError(error.Trailing, decode(raw[0..bytes.len])); - // Smaller than a header at all: a number no encoder produced. Refused - // before it is compared against the buffer, or a size of 3 on a 3-byte - // buffer would slice a header out of nothing. for ([_]u32{ 0, 1, 6 }) |claim| { std.mem.writeInt(u32, raw[0..4], claim, .little); try testing.expectError(error.BadValue, decode(raw[0..bytes.len])); @@ -1456,35 +987,22 @@ test "9p: an unknown or illegal type byte is refused" { var raw: [64]u8 = undefined; @memcpy(raw[0..bytes.len], bytes); - // 106 is `Terror`, defined and illegal. 8 is 9P2000.L's `Tstatfs`, 12 is - // its `Tlopen`: dialects we do not serve, arriving as bytes we must refuse - // rather than `@enumFromInt` into an exhaustive enum. for ([_]u8{ 0, 1, 8, 12, 99, 106, 128, 255 }) |t| { raw[4] = t; try testing.expectError(error.BadTag, decode(raw[0..bytes.len])); } - // ...and the whole byte space, because the guarantee is total: a byte is - // either a type we decode into a message of exactly that type, or an - // error. Never a panic, and never a message of some OTHER type. var t: u16 = 0; while (t <= 255) : (t += 1) { raw[4] = @intCast(t); const defined = t >= 100 and t <= 127 and t != @intFromEnum(Type.terror); if (decode(raw[0..bytes.len])) |got| { try testing.expectEqual(@as(u8, @intCast(t)), @intFromEnum(got.msg.msgType())); - // Exactly four types have a four-byte body: `fid[4]` for the three - // T-messages and `count[4]` for Rwrite. Nothing else may decode - // out of these bytes, and a fifth name here would mean a layout - // above is wrong. try testing.expect(t == @intFromEnum(Type.tclunk) or t == @intFromEnum(Type.tremove) or t == @intFromEnum(Type.tstat) or t == @intFromEnum(Type.rwrite)); } else |err| { - // An undefined byte, or the illegal 106, is ALWAYS BadTag: it must - // never be diagnosed as a short body, because "read more" is the - // wrong advice for a peer speaking another dialect. if (!defined) try testing.expectEqual(Error.BadTag, err); } } @@ -1493,9 +1011,6 @@ test "9p: an unknown or illegal type byte is refused" { test "9p: trailing bytes inside the size are refused" { var raw: [64]u8 = undefined; - // A Tclunk whose `size` says twelve and whose body is five bytes: the fid - // decodes, and one byte is left over. `convM2S.c:377-381` refuses the same - // shape with `if(ap+size == p) return size; return 0;`. var w: Writer = .init(&raw); try w.putU32(12); try w.putByte(@intFromEnum(Type.tclunk)); @@ -1505,8 +1020,6 @@ test "9p: trailing bytes inside the size are refused" { try testing.expectEqual(@as(usize, 12), w.n); try testing.expectError(error.Trailing, decode(raw[0..12])); - // Same for a body with room for a second copy of itself, which is how a - // 9P2000.u message with an extra field would arrive. w = .init(&raw); try w.putU32(header_len + 2 + 2); try w.putByte(@intFromEnum(Type.tflush)); @@ -1521,14 +1034,10 @@ test "9p: frameLen needs four bytes" { const bytes = try encode(.{ .tread = .{ .fid = 1, .offset = 0, .count = 8168 } }, 1, &buf); try testing.expectEqual(@as(usize, 23), bytes.len); - // Zero through three: the reader has nothing to act on but "read more". for (0..4) |n| try testing.expectEqual(@as(?u32, null), frameLen(bytes[0..n])); - // Four is enough, and the answer is the whole message including the four. try testing.expectEqual(@as(?u32, 23), frameLen(bytes[0..4])); try testing.expectEqual(@as(?u32, 23), frameLen(bytes)); - // Unvalidated on purpose: the type byte may not have arrived yet, so there - // is nothing to check the claim against. A caller compares it to its msize. var raw: [4]u8 = .{ 0xFF, 0xFF, 0xFF, 0xFF }; try testing.expectEqual(@as(?u32, std.math.maxInt(u32)), frameLen(&raw)); raw = .{ 0, 0, 0, 0 }; @@ -1539,15 +1048,10 @@ test "9p: encode refuses a short buffer and writes nothing" { var buf: [512]u8 = undefined; const want = (try encode(.{ .rstat = .{ .stat = sample_stat } }, 1, &buf)).len; - // Every buffer from empty to one byte short, because the interesting one is - // not always the last: the message is sized before a byte is written, so - // all of them must leave the buffer untouched. var n: usize = 0; while (n < want) : (n += 1) { var scratch: [512]u8 = @splat(0xAA); try testing.expectError(error.NoSpace, encode(.{ .rstat = .{ .stat = sample_stat } }, 1, scratch[0..n])); - // NOTHING written, not even the size prefix — including past the end of - // the slice it was given, which is the byte a length bug would reach. for (scratch) |b| try testing.expectEqual(@as(u8, 0xAA), b); } @@ -1559,15 +1063,6 @@ test "9p: encode refuses a short buffer and writes nothing" { test "9p: byte for byte against u9fs convS2M" { var buf: [512]u8 = undefined; - // A round-trip test proves the two halves of THIS file agree. These four - // prove they agree with the reference implementation, which is what - // plan9port's `9p`, Plan 9's mount driver and Linux's v9fs are all - // compatible with. Each array was written out by hand from `convS2M.c` and - // the line is named. - - // Tversion, `convS2M.c:236-240` with the header at :224-229. - // size[4]=19 type[1]=100 tag[2]=NOTAG msize[4]=8192 version[2+6] - // 19, not 12: `size` counts itself and the type and the tag. try testing.expectEqualSlices(u8, &.{ 0x13, 0x00, 0x00, 0x00, // size = 19 0x64, // Tversion = 100 @@ -1579,8 +1074,6 @@ test "9p: byte for byte against u9fs convS2M" { '0', '0', }, try encode(.{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, notag, &buf)); - // Twalk, `convS2M.c:272-283`: fid, newfid, nwname, then `pstring` each, - // and `pstring` (:4-16) writes `n[2]` with NO terminator. try testing.expectEqualSlices(u8, &.{ 0x1b, 0x00, 0x00, 0x00, // size = 27 0x6e, // Twalk = 110 @@ -1600,8 +1093,6 @@ test "9p: byte for byte against u9fs convS2M" { .wname = .{ "usr", "bin" } ++ @as([max_welem - 2][]const u8, @splat("")), } }, 1, &buf)); - // Rread, `convS2M.c:392-397`: count[4] then the bytes. The 11-byte header - // this implies is where `msize - 11` comes from (docs/registry.typ). try testing.expectEqualSlices(u8, &.{ 0x0e, 0x00, 0x00, 0x00, // size = 14 0x75, // Rread = 117 @@ -1610,9 +1101,6 @@ test "9p: byte for byte against u9fs convS2M" { 'a', 'b', 'c', }, try encode(.{ .rread = .{ .data = "abc" } }, 9, &buf)); - // Rstat, `convS2M.c:410-415` (`PBIT16(p, f->nstat)` then the blob) around - // `convD2M.c:50-83` (the record, whose own prefix is `ss - BIT16SZ`). THE - // double length, in bytes: 53 outside, 51 inside, 55 of body, 62 total. const one: Stat = .{ .type = 0, .dev = 0, @@ -1648,9 +1136,6 @@ test "9p: byte for byte against u9fs convS2M" { 0x01, 0x00, 'm', // muid }, try encode(.{ .rstat = .{ .stat = one } }, 7, &buf)); - // The high five mode bits ARE the qid type bits, shifted down 24 - // (`protocol.rs:486-495`). Asserted here rather than implemented, because - // this codec carries both fields and the server half sets them. try testing.expectEqual(qtdir, @as(u8, @intCast(dmdir >> 24))); try testing.expectEqual(qtappend, @as(u8, @intCast(dmappend >> 24))); try testing.expectEqual(qtexcl, @as(u8, @intCast(dmexcl >> 24))); @@ -1659,135 +1144,25 @@ test "9p: byte for byte against u9fs convS2M" { try testing.expectEqual(@as(u32, 0o777), dmperm); } -// =========================================================================== -// THE SERVER HALF -// =========================================================================== -// -// Everything above is the wire. Everything below turns a stream of those -// messages into `acmefs.Req` and back. -// -// IT IS A SANS-IO STATE MACHINE and it never touches a descriptor: the caller -// pushes bytes in with `push`, pumps requests through the core with -// `retry`/`next`/`reply`, and takes bytes out with `output`/`wrote`. There is -// no socket here, no poll, no thread and no allocator, which is the whole -// point — the same code serves a unix socket on Linux, a TCP connection from -// another machine, and the board's UART, and the transport-specific part is -// two syscalls in the caller. -// -// WHAT IT IS NOT. It is not a filesystem: every question about what a file -// MEANS belongs to `acmefs.zig`, and this half knows only that a node id is a -// u64, that some nodes are directories, and that a reply may say "ask me -// later". There are exactly two exceptions, both named and both forced by the -// absence of a kernel: the root's node id, which arrives as a parameter to -// `init`, and `parentOf`, which is where `..` goes. -// -// Verified against `u9fs/u9fs.c` (the tree and the offset rules), -// `linux/net/9p/{client,error}.c` (what a real client does with our answers), -// `principia-softwarica/lib_networking/lib9p/srv.c` (flush ordering) and -// `ad/crates/ninep/src/sansio/server.rs` (the scars in its git history). - -// --------------------------------------------------------------------------- -// the error ABI -// --------------------------------------------------------------------------- -// -// `Rerror` carries a STRING and base 9P2000 has no number beside it, so the -// WORDING IS THIS SERVER'S ERROR ABI. Linux recovers an errno by exact match -// against a fixed table and a miss is not `EIO` but `ESERVERFAULT`, which -// userspace prints as "Unknown error 526" — so every string below is copied -// character for character out of `linux/net/9p/error.c:41-171`, with the errno -// it maps to named beside it. -// -// THIS WAS A CHOICE and `docs/registry.typ` `9P-4` left it open with three -// candidates. This is OPTION A. Option B was to serve 9P2000.u and send the -// number, which also restores `Tstatfs` — the one `acmefs.Op` with no -// base-9P2000 message — and keeps a human-readable string for Plan 9 clients; -// it costs a second dialect inside every parser above, because `.u` changes -// the LAYOUT of `Rerror` and `stat` rather than adding messages. Option C was -// acme's own wording (`Ebadctl`, `Ebadaddr`, `Ebadevent`), which is a table -// miss for every one of them; that is what `ad` shipped, so under -// `mount -t 9p` every error it can produce arrives as 526. -// -// The cost of option A is stated plainly: English kernel strings become this -// project's error ABI, and a script reading `event` sees "Invalid argument" -// where acme would have said something about a read too small. If `.u` is ever -// served this table stays as it is — `.u`'s `Rerror` carries the string too. - -/// EBADF. The fid a message names was never walked to, or has been clunked. -/// u9fs spells it `Ebadfid` (`u9fs.c:119`). pub const e_unknown_fid = "fid unknown or out of range"; -/// EBADF. `Tattach` or `Twalk` named a `newfid` that is already bound. u9fs -/// `Efidactive` (`u9fs.c:124`). pub const e_fid_in_use = "fid already in use"; -/// EBADF. A fid used for something its state does not allow: read on a fid -/// that was never opened, write on one opened `OREAD`, walk from an open one. -/// u9fs `Ebadusefid` (`u9fs.c:121`), whose five uses are ours. pub const e_bad_use = "bad use of fid"; -/// ESPIPE. A directory read at an offset that is neither zero nor exactly -/// where the last one ended. u9fs `Ebadoffset` (`u9fs.c:763`). pub const e_bad_offset = "bad offset in directory read"; -/// EACCES. The permission bits the core reported do not admit this open, or -/// the message asked for something a generated tree cannot do: create, remove, -/// remove-on-close, execute. pub const e_perm = "permission denied"; -/// ENOTDIR. A walk with names from a fid that is not a directory. pub const e_not_dir = "not a directory"; -/// ETXTBSY. A second `Topen` on one fid. The fid IS the open, so there is -/// nothing for the second one to mean. pub const e_already_open = "file already open for I/O"; -/// ENAMETOOLONG. A walk element longer than `name_max`. No name in this tree -/// is, so this is a client asking for something that cannot exist — refused on -/// its length rather than looked up, because the fid has to be able to hold -/// the name it lands on (`Rstat` carries it). pub const e_illegal_name = "illegal name"; -/// ENFILE. The fid table is full. Thirty-two is a lot of scripts. pub const e_too_many_fids = "Too many open files in system"; -/// EPROTO. Not 9P2000 on this connection: an R-message arriving at a server, a -/// type byte no dialect we serve defines, a body that does not parse, a -/// message larger than the negotiated msize, or anything at all before -/// `Tversion`. pub const e_botch = "protocol botch"; -/// EINTR. What a flushed request is answered with, immediately before its -/// `Rflush`. The same answer `fuse.zig:1338` gives a `FUSE_INTERRUPT`. pub const e_interrupted = "Interrupted system call"; -/// EPERM. A `Twstat` carrying a non-zero length. The core honours exactly one -/// field and only the value zero (`acmefs.zig:1096-1104`), and this string -/// says so in a wording Linux already knows. pub const e_trunc_only = "only support truncation to zero length"; -/// EPERM. A `Twstat` that would rename, or otherwise change the shape of a -/// tree that follows the pane list. pub const e_wstat = "wstat prohibited"; -/// EAGAIN. The park table is full, or the core parked a request whose payload -/// is too large to copy into a slot. Both are honest to a client: retry. pub const e_again = "Resource temporarily unavailable"; -/// EINVAL. A read whose count cannot hold the first thing the answer consists -/// of — one directory entry. Refused rather than answered short, because a -/// `Tread` returning zero bytes is END OF DIRECTORY and a client that believes -/// it stops asking. The same rule `acmefs` already applies to an `event` read -/// too small for one record, and `9P-17` records that this is what makes the -/// clamp to `count` safe. pub const e_count_small = "Invalid argument"; -/// ENOENT. `Tattach` named an `aname`. There is one tree here and it has no -/// name; a client that asked for a different one should learn that now rather -/// than be handed this one (docs/9p.typ §12.4: "no `aname`"). pub const e_no_tree = "No such file or directory"; -/// Not in Linux's table, and deliberately: Linux's client never sends `Tauth` -/// at all, and Plan 9's `mount` treats an error here as "no authentication -/// needed" and carries on. So this string is chosen for the human reading a -/// Plan 9 error message rather than for `p9_errstr2errno`. u9fs says the same -/// thing in a string that maps to zero — "not an error" — which is a subtlety -/// we do not need. Real authentication is `Tauth` or a tunnel, and neither is -/// ours (docs/9p.typ §10). pub const e_no_auth = "authentication not required"; -/// EINVAL. `Tversion` offered an msize too small to serve (see `msize_min`). -/// `Rversion` has no way to say this — its `version` field means "no dialect -/// in common", which is a different fact — and `Rerror` is a legal reply to -/// any T-message, so this is the honest channel. pub const e_small_msize = "Invalid argument"; -/// The core's numeric errno as the string Linux turns back into that same -/// number. Every value `acmefs.E` defines is here by name; anything else -/// becomes EIO, because a number we did not choose to emit is a bug in this -/// file and "Input/output error" is the one answer that is never misleading. pub fn errString(errno: u16) []const u8 { return switch (errno) { 1 => "Operation not permitted", // E.PERM, EPERM @@ -1803,258 +1178,52 @@ pub fn errString(errno: u16) []const u8 { }; } -// --------------------------------------------------------------------------- -// open modes -// --------------------------------------------------------------------------- -// -// `Topen.mode`, from `u9fs/plan9.h:146-153`. The codec above carries the byte -// and has no opinion about it; these are what the byte MEANS, which is the -// server's business. - -/// The low two bits, which are a VALUE and not a mask: 0, 1, 2, 3. pub const oread: u8 = 0; pub const owrite: u8 = 1; pub const ordwr: u8 = 2; -/// «execute, == read but check execute permission». Nothing in this tree is a -/// program, so it is refused rather than treated as a read. pub const oexec: u8 = 3; -/// Or'ed in. Truncate first — this is how a shell's `>` reaches a 9P server, -/// and it maps onto `acmefs.Req.truncate` exactly as a `Twstat` with a zero -/// length does (docs/registry.typ `FIX-1`). pub const otrunc: u8 = 16; -/// Or'ed in, close on exec. A CLIENT-SIDE flag: Plan 9's kernel consumes it -/// and never sends it, so a server that sees it may ignore it, and we do. pub const ocexec: u8 = 32; -/// Or'ed in, remove on close. Refused: this tree's shape follows the pane list -/// and there is nothing in it a client may remove. pub const orclose: u8 = 64; -// --------------------------------------------------------------------------- -// sizes -// --------------------------------------------------------------------------- - -/// Fids one connection may hold at once. A FIXED ARRAY and not a map, costed -/// in `docs/registry.typ` `9P-11`: a linear scan is far cheaper than the wire, -/// so the map would buy nothing and cost an allocator this file does not have. -/// -/// 256 AND NOT 32, which is what it was, and the difference is a MOUNT. A -/// script that opens one file at a time never needs more than a handful; a -/// mounting client keeps one fid per cached inode, and this tree is three -/// top-level entries plus fourteen files per pane, so seven panes already pass -/// thirty-two and a full sixteen-pane session wants over two hundred. At the -/// old number `find` over a `9pfuse` mount failed with fifty-seven consecutive -/// `Rerror`s once the table filled — and `9pfuse` is the proof clause -/// `docs/9p.typ` §12.4 sets for this step, so the number was refuting its own -/// acceptance test. -/// -/// A `Fid` is about 64 bytes, so this is ≈16 KiB per connection against the -/// ≈34 KiB `fs9_service.zig` already budgets for one. The BOARD keeps thirty-two -/// by passing its own value: see `board_fids`, and `9P-11`'s RAM line, which is -/// costed for a microcontroller serving its own small tree and nothing else. -/// -/// Overflow is a refusal (`e_too_many_fids`), not a queue. pub const max_fids: usize = 256; -/// What a microcontroller uses instead. Named here rather than spelled at the -/// call site so that the two numbers, and the reason they differ, stay next to -/// each other. pub const board_fids: usize = 32; -/// Requests that may be outstanding at once — in flight, or parked because the -/// core answered `.again`. In practice this counts BLOCKED READERS: one slot -/// per process sitting on `event`. The number is `src/fuse.zig:793`'s, -/// unchanged, because `Status.again` means the same thing to both transports. pub const max_slots: usize = 32; -/// Bytes of request payload a park slot owns. A parked request's `data` cannot -/// go on borrowing the input buffer — the next message overwrites it — so it -/// is copied in when it fits. -/// -/// Smaller than `fuse.zig`'s 512, for a reason specific to this file: under -/// FUSE a LOOKUP name is a payload and may be 255 bytes, while a 9P walk -/// element is consumed inside the walk and never parks. What is left is a -/// write, and the only writes that could conceivably block are a `ctl` verb -/// line and an event write-back, both a few dozen bytes. A larger write that -/// the core tries to park is answered `e_again` — honest, and by construction -/// unreachable, since the core answers writes as transactions. pub const park_data_max: usize = 128; -/// The longest name a fid may land on, and the longest `uname` we keep. -/// -/// A BOUND rather than a buffer size: `Rstat` carries the file's name, so a -/// fid has to hold the name it walked to, and this is the number that makes -/// `msize_min` provable. Every name in the tree fits with room over — the -/// longest are a pane's decimal serial and `errors` — so a walk element longer -/// than this is refused as `e_illegal_name` rather than looked up and then -/// truncated, which would make `Rstat` lie. -pub const name_max: usize = 28; - -/// The smallest msize this server will agree to serve. DERIVED, not chosen: -/// `Rwalk` with the protocol's sixteen qids is the largest reply whose size -/// the client cannot influence after the handshake, so a connection that -/// cannot hold one cannot be served at all. -/// -/// Deliberately NOT `min_msize` (4096), which is the LINUX KERNEL's floor and -/// nobody else's: Plan 9's devmnt, plan9port's `9p` and pardes's own client all -/// accept 512, and the board would rather have the kilobytes back. +pub const board_name_capacity: usize = 28; +const username_capacity: usize = 28; + pub const msize_min: u32 = header_len + 2 + max_welem * qid_len; comptime { assert(msize_min == 217); - // The other two replies whose size the client does not choose: `Rstat` - // carries one record with four strings, and a directory read must fit at - // least one such record or it can never make progress. Both must clear - // `msize_min`, or the floor above is not a floor. - assert(header_len + 2 + stat_fixed + 4 * name_max <= msize_min); - assert(header_len + 4 + stat_fixed + 4 * name_max <= msize_min); - // A pane serial is a u60, so its decimal name is at most twenty digits and - // `parentOf` cannot overflow the buffer it formats into. - assert(name_max >= 20); - // Modes are a value in the low two bits with flags above them. + assert(header_len + 2 + stat_fixed + board_name_capacity + 3 * username_capacity <= msize_min); + assert(header_len + 4 + stat_fixed + board_name_capacity + 3 * username_capacity <= msize_min); + assert(username_capacity >= 20); assert(oread | owrite | ordwr | oexec == 3); assert(otrunc | ocexec | orclose == 112); } -/// The server's name for a node. -/// -/// `qid.version` IS ALWAYS ZERO, and this is a policy rather than a -/// translation. It is the 9P equivalent of the `FOPEN_DIRECT_IO` that -/// `src/fuse.zig:172-176` relies on, and it is server-side rather than advice -/// to whoever mounts: Linux's client sets `P9L_DIRECT` — «no read or write -/// cache» — for any file whose qid version is zero, whatever the cache mode, -/// unless `ignoreqv` is passed explicitly (`linux/fs/9p/fid.h:52-53`, -/// `v9fs.c:93`). A synthetic tree of live editor state has no business being -/// cached: `body` changes under the reader's feet, `event` is a queue, and a -/// cached lookup under `new/` would create one pane and then serve the same -/// answer forever. Plan 9 needs nothing said to it — its cache is opt-in via -/// `mount -c` (docs/registry.typ `9P-3`). -/// -/// `qid.path` is the core's node id UNCHANGED, which is what makes the two -/// transports agree: one integer is a FUSE nodeid, a `d_ino` and a qid path at -/// once, and it never comes to mean a different file because pane serials are -/// never reused (`acmefs.zig:231-237`). fn qidOf(node: u64, dir: bool) Qid { return .{ .type = if (dir) qtdir else qtfile, .version = 0, .path = node }; } -/// Where `..` goes, and the ONE place in this file that decodes a node id. -/// -/// WHY THIS IS HERE AT ALL, because it is the fact that gets lost: under FUSE -/// the kernel resolves `.` and `..` in the pathname before a request is ever -/// sent, which is what lets `acmefs.zig:840` say they «are the kernel's -/// business, never ours». Under 9P THERE IS NO KERNEL. `Twalk` carries `..` as -/// an ordinary name element, and a client that normalises a path, or walks up -/// before walking down, sends it. Forwarding it to the core as a lookup would -/// answer `ENOENT` and break `cd ..`, so the server answers it. -/// -/// It can, without asking anything, because the tree has fixed depth and the -/// node id says where you are. `acmefs.Node` is -/// `packed struct(u64){ file: u4, serial: u60 }` (`acmefs.zig:238-240`), so -/// `file` is the low four bits and `serial` is everything above them, and: -/// -/// * at the root, `..` is the root. POSIX's rule and `intro(5)`'s: the root -/// is its own parent, and this is not an error. -/// * `serial == 0` is a top-level file (`index`, `cons`, `new`), whose -/// parent is the root. -/// * `file == 0` is `PaneFile.dir`, a pane's own directory, whose parent is -/// the root. -/// * anything else is a file inside a pane's directory, and its parent is -/// that directory: the same serial with `file` cleared. Its NAME is the -/// serial in decimal, which is how `acmefs`'s root lists it -/// (`acmefs.zig:992-994`). -/// -/// A well-behaved client only walks between directories, so the last case -/// should never arrive; it is answered correctly rather than trusted away. -/// -/// THE DEPTH ASSUMPTION IS THE WHOLE OF WHAT COULD ROT, and it is checked by -/// the shape of the node id rather than by hope: `Node` has ONE `file: u4`, so -/// a level below a pane's directory — `docs/9p.typ`'s `pty/` — cannot be -/// encoded in a node id at all today. If that changes, this function is the -/// one place that has to learn about it. -fn parentOf(node: u64, root: u64, buf: *[name_max]u8) struct { node: u64, name_len: u8 } { - const serial = node >> 4; - const file = node & 0xF; - if (node == root or serial == 0 or file == 0) { - buf[0] = '/'; - return .{ .node = root, .name_len = 1 }; - } - // A u60 is twenty decimal digits at most and `name_max` is checked against - // that above, so the format cannot fail. - const name = std.fmt.bufPrint(buf, "{d}", .{serial}) catch unreachable; - return .{ .node = serial << 4, .name_len = @intCast(name.len) }; -} - -/// The permission bits a DIRECTORY ENTRY reports, and the only place in this -/// file that reports a mode it was not told. -/// -/// `acmefs`'s staging format for a readdir is `node[8] dir[1] namelen[1] -/// name[]` (`acmefs.zig:942-957`) — the same record the FUSE transport decodes -/// — and it carries no mode and no length, because under FUSE the kernel asks -/// for those separately, with a `getattr` per entry it decides it wants. 9P -/// puts a whole `stat` in a directory read, so the choice is between a -/// `getattr` per entry — an extra round trip each, and a third msize buffer to -/// hold the entries across it — and reporting the tree's own defaults here. -/// -/// We report the defaults. `0o500` is what `TopFile.mode` and `PaneFile.mode` -/// give every directory in the tree without exception; `0o600` is what -/// `PaneFile.mode` gives every file but three; a length of zero is the true -/// length of every file here but `body`, `tag` and `index`. -/// -/// WHO SEES THE DIFFERENCE: only a client that reads permissions and sizes out -/// of a DIRECTORY READ, which is Plan 9's `ls -l` and nothing else. Linux's -/// v9fs takes names and qids from the read and stats each file separately, -/// 9pfuse does the same, and `Tstat` here answers out of the core's own -/// `getattr` — so `ls -l` through either of those is exact. pub const dirent_dir_perm: u16 = 0o500; pub const dirent_file_perm: u16 = 0o600; -/// A 9P2000 server for one connection, over the filesystem ABI `fs`. -/// -/// WHY THIS IS A GENERIC and not a plain struct that imports `acmefs.zig`: -/// this file is freestanding-safe and must stay so — it compiles for -/// `wasm32-freestanding` and the board's `riscv32-freestanding`, and -/// `acmefs.zig` reaches `pardes.zig`, which reaches the build's generated -/// modules. Importing it would also drag every test in that graph into -/// `zig test src/9p.zig`. So the ABI arrives as a type parameter and the -/// coupling is exactly three declarations: -/// -/// * `fs.Req` with `tag, op, node, handle, off, size, data, truncate` -/// * `fs.Reply` with `tag, status, errno, attr, handle, written` -/// * `fs.Reply.Attr` with `node, dir, size, mode` -/// -/// which is `acmefs`'s ABI verbatim, so the real instantiation is -/// `Server(acmefs)` and it needs no translation layer at all. The `Op` and -/// `Status` values are reached as enum literals (`.lookup`, `.again`), so they -/// are checked against the real enums at that instantiation. The tests below -/// instantiate it on a stub filesystem, which is how they run with no core. -/// -/// THE THREE METHODS `src/fs_service.zig`'s `Transport` wants — `retry`, -/// `next` and `reply` — are here with those names and those shapes, and the -/// order contract is that file's: `retry()` to null first, then `next()` to -/// null. `fs_service` is deliberately NOT imported (it is `std.c` and -/// `pardes.zig` deep); the adapter that fills in a vtable is three functions -/// in whoever owns the socket. -/// -/// MEMORY, all of it caller-supplied or fixed: the two buffers, a fid table of -/// `max_fids` and a park table of `max_slots`. No allocator, and nothing here -/// grows. -pub fn Server(comptime fs: type) type { +pub fn Server(comptime fs: type, comptime fid_capacity: usize) type { + if (fid_capacity == 0) @compileError("9P server needs at least one fid"); + const name_capacity = if (@hasDecl(fs, "name_capacity")) fs.name_capacity else board_name_capacity; + if (name_capacity == 0 or name_capacity > 255) @compileError("9P backend name capacity must fit a directory entry"); return struct { const Self = @This(); - /// Bytes the caller has pushed and we have not finished with. - /// `in[0..frame]` is the message being served when `frame != 0`, and - /// every slice a decoded `Msg` holds points into it — which is why - /// nothing compacts this buffer until that message is done with. in: []u8, - /// Encoded replies, oldest first, as a byte FIFO. Every 9P message - /// carries its own length, so the queue needs no side table: the - /// caller writes `output()` and tells us how much went. out: []u8, - /// The node id of the tree's root — `@intFromEnum(acmefs.TopFile.root)` - /// — and the one fact about the tree this file is told rather than - /// deriving. `Tattach` needs somewhere to start and 9P has no way to - /// ask for it. root: u64, in_len: usize = 0, @@ -2062,108 +1231,53 @@ pub fn Server(comptime fs: type) type { out_len: usize = 0, out_off: usize = 0, - /// Negotiated by `Tversion`; ZERO means not yet, and nothing but - /// `Tversion` is served in that state. msize: u32 = 0, - /// The stream is not 9P and there is no resynchronising from it: stop - /// serving and let the caller close. Write-once, like `fuse.Fs.dead`. dead: bool = false, - /// Whoever attached, for `Rstat`'s three name fields. The tree is - /// synthetic and has one owner: the client that opened the connection. - uname: [name_max]u8 = @splat(0), + uname: [username_capacity]u8 = @splat(0), uname_len: u8 = 0, - fids: [max_fids]Fid = @splat(.{}), + fids: [fid_capacity]Fid = @splat(.{}), slots: [max_slots]Slot = @splat(.{}), - /// The message being served. At most one, which is what keeps the - /// walk's accumulated qids and the borrowed names in one place instead - /// of in thirty-two slots. job: Job = .{}, - /// Hands out `fs.Req.tag`s, and orders the park table. Never zero, so - /// that zero can mean "no request outstanding". seq: u64 = 0, - /// What a 9P message is being turned into. The reply's SHAPE, which is - /// what `reply` needs and what `Op` alone does not say: a `getattr` is - /// a step of `Rattach`, of `Rwalk` and of `Rstat`. const Kind = enum { none, attach, walk, open, read, readdir, write, clunk, remove, stat, wstat }; - /// One fid: a name the client gave a place in the tree. - /// - /// `perm` and `dir` are cached from the attributes the walk that landed - /// here already answered, because `Topen` has to check permission - /// itself — there is no kernel above us doing it, and `acmefs.open` - /// deliberately does not (`acmefs.zig:1023-1031`). `name` is cached - /// because `Rstat` carries it and a node id does not. const Fid = struct { used: bool = false, - /// The client's number. `nofid` is never one. fid: u32 = 0, node: u64 = 0, dir: bool = false, - /// Permission bits as the core last reported them, which is what - /// `Topen` is checked against. perm: u16 = 0, open: bool = false, - /// The `Topen` mode, valid when `open`. omode: u8 = 0, - /// `acmefs`'s open handle, repeated on every read, write and - /// release. handle: u32 = 0, - /// THE DIRECTORY CURSOR, in the two coordinate systems it has to - /// live in at once: `diroff` is the BYTE offset 9P requires the - /// next read to carry, and `dirindex` is the ENTRY INDEX `acmefs` - /// counts in (`acmefs.zig:972`, `var skip = req.off;`). diroff: u64 = 0, dirindex: u32 = 0, - /// This fid has no client any more and still owes the core a - /// `release`. See `orphan`. orphan: bool = false, - name: [name_max]u8 = @splat(0), + name: [name_capacity]u8 = @splat(0), name_len: u8 = 0, }; - /// A request the core would not answer yet. Lifted from - /// `src/fuse.zig:806-822` with the FUSE opcode replaced by the 9P tag - /// and the reply shape, because `Status.again` means the same thing to - /// both transports and this is where `docs/registry.typ` `9P-16` says - /// we beat the prior art. const Slot = struct { used: bool = false, - /// The core answered `.again`; `retry()` will offer it back. parked: bool = false, - /// Already offered in this retry round. Reset when a round finds - /// nothing, which gives every parked request exactly one attempt - /// per frame instead of letting the oldest starve the rest. retried: bool = false, - /// `req.data` points into `data` below rather than into `in`. copied: bool = false, - /// Arrival order, so retries are FIFO: the reader that blocked - /// first is offered first. seq: u64 = 0, - /// The client's tag, which is what `Tflush` names. tag: u16 = 0, kind: Kind = .none, - /// The client's fid NUMBER and not an index: the fid may be - /// clunked while this is parked, and a stale index would be a - /// stale pointer. fid: u32 = 0, - /// What the client asked for, which is what the answer is clamped - /// to (`docs/registry.typ` `9P-17`). count: u32 = 0, req: fs.Req = undefined, data: [park_data_max]u8 = undefined, }; - /// The message in flight, and the accumulated answer. const Job = struct { kind: Kind = .none, tag: u16 = 0, - /// The `fs.Req.tag` of the step the core is holding, or zero. req_tag: u64 = 0, - /// The step itself, kept so that a park has something to copy and - /// a retry has something to re-offer. req: fs.Req = undefined, step: u8 = 0, fid: u32 = 0, @@ -2171,54 +1285,37 @@ pub fn Server(comptime fs: type) type { count: u32 = 0, offset: u64 = 0, omode: u8 = 0, - /// Where the walk has got to: the node, its attributes and its - /// name, all of which `Rwalk`'s last qid and the bound fid need. node: u64 = 0, dir: bool = false, perm: u16 = 0, - name: [name_max]u8 = @splat(0), + name: [name_capacity]u8 = @splat(0), name_len: u8 = 0, nwname: u8 = 0, nwqid: u8 = 0, wqid: [max_welem]Qid = @splat(.{ .type = 0, .version = 0, .path = 0 }), - /// The decoded T-message, BORROWING `in[0..frame]`: a walk's names - /// and a write's bytes live here and nowhere else. msg: Msg = .rflush, }; pub const Options = struct { - /// Room for one whole T-message. Caps the msize we will agree to, - /// with `out`. in: []u8, - /// Room for two: one being written out and one being built. That - /// is what lets a reply be encoded the moment the core answers, - /// with no "can I write yet" question anywhere in this file. out: []u8, - /// `@intFromEnum(acmefs.TopFile.root)`. root: u64, }; - /// The buffers are the caller's, which is what "no allocator" means - /// here: the board hands over two static arrays, a desktop host hands - /// over two heap slices sized for a 128 KiB msize, and this file cannot - /// tell the difference. The msize follows from them and from the - /// client's `Tversion`; see `version`. pub fn init(opts: Options) Self { assert(opts.in.len >= msize_min); assert(opts.out.len >= 2 * msize_min); - // Node zero is `acmefs.Node{}` — no file, no pane — and cannot be - // a root. A zero here would make every `..` land on nothing. assert(opts.root != 0); return .{ .in = opts.in, .out = opts.out, .root = opts.root }; } - /// The connection went away. Every open fid still owes the core a - /// `release`, and that debt outlives the connection: an `event` fid - /// dropped without one leaves the pane's reader count high forever, - /// which leaves the editor reporting button actions to a script that - /// is no longer there (`acmefs.zig:1053-1061`). So the fids are - /// ORPHANED rather than forgotten, and the caller keeps pumping - /// `next()` until it answers null. + pub fn references(s: *const Self, node: u64) bool { + for (s.fids) |fid| if (fid.used and fid.node == node) return true; + if (s.job.kind != .none and s.job.node == node) return true; + for (s.slots) |slot| if (slot.used and slot.req.node == node) return true; + return false; + } + pub fn hangup(s: *Self) void { s.reset(); s.dead = true; @@ -2228,12 +1325,6 @@ pub fn Server(comptime fs: type) type { s.out_off = 0; } - /// What `Tversion` does to the connection, and what `hangup` does - /// first: «all fids are clunked and any outstanding I/O is abandoned» - /// (`version(5)`). The parked requests go without an answer, which is - /// exactly what abandoned means; the fids that are open become - /// orphans, because the core's side of an open is not the client's to - /// abandon. fn reset(s: *Self) void { for (&s.fids) |*f| { if (!f.used) continue; @@ -2243,13 +1334,6 @@ pub fn Server(comptime fs: type) type { s.job = .{}; } - // -- bytes in, bytes out --------------------------------------------- - - /// Take as much of `bytes` as there is room for, and answer how much. - /// A short answer is not an error and not a loss: it is the only - /// back-pressure a sans-io server has, and the caller re-offers the - /// tail after pumping. Bytes are APPENDED, so a message already being - /// served does not move. pub fn push(s: *Self, bytes: []const u8) usize { if (s.dead) return 0; const n = @min(bytes.len, s.in.len - s.in_len); @@ -2258,14 +1342,10 @@ pub fn Server(comptime fs: type) type { return n; } - /// The replies waiting to go, oldest first, as one contiguous run of - /// whole 9P messages. Valid until the next call to anything else here. pub fn output(s: *const Self) []const u8 { return s.out[s.out_off..s.out_len]; } - /// How many of `output()`'s bytes actually left. A partial write is - /// normal on a UART and on a full socket, and the remainder stays put. pub fn wrote(s: *Self, n: usize) void { assert(n <= s.out_len - s.out_off); s.out_off += n; @@ -2275,9 +1355,6 @@ pub fn Server(comptime fs: type) type { } } - /// Slide the unwritten tail down. Called only when room is wanted, so - /// the common case — a fully written queue, reset to empty by `wrote` — - /// never moves a byte. fn compact(s: *Self) void { assert(s.out_off <= s.out_len); const n = s.out_len - s.out_off; @@ -2286,22 +1363,11 @@ pub fn Server(comptime fs: type) type { s.out_len = n; } - /// THE RESERVATION RULE, and the reason no reply in this file can ever - /// fail to be written: a request is not handed to the core unless the - /// out queue already has room for the largest answer it could produce, - /// which is one msize. So `emit` cannot run out, a parked read that - /// completes cannot be dropped, and back-pressure lands where it can - /// be dealt with — `next()` and `retry()` answer null, the caller - /// writes some bytes, and the pump continues. fn hasRoom(s: *Self) bool { if (s.out_off != 0) s.compact(); return s.out.len - s.out_len >= @max(s.msize, msize_min); } - /// Queue one reply. Infallible by the reservation rule above; if it - /// ever is not, the connection dies rather than the stream growing a - /// half-written message — a dropped reply hangs a client forever, - /// while a closed connection makes it fail and say so. fn emit(s: *Self, tag: u16, msg: Msg) void { const bytes = encode(msg, tag, s.out[s.out_len..]) catch { s.dead = true; @@ -2315,9 +1381,6 @@ pub fn Server(comptime fs: type) type { s.emit(tag, .{ .rerror = .{ .ename = ename } }); } - /// The next `fs.Req.tag`. Unique for the life of the connection, which - /// is what lets `reply` find its target with no cooperation from the - /// core, and never zero. fn tick(s: *Self) u64 { s.seq += 1; return s.seq; @@ -2347,27 +1410,17 @@ pub fn Server(comptime fs: type) type { return null; } - /// A parked request by the tag the CLIENT gave it, which is what - /// `Tflush` names. fn findTag(s: *Self, tag: u16) ?usize { for (&s.slots, 0..) |*sl, i| if (sl.used and sl.tag == tag) return i; return null; } fn setUname(s: *Self, uname: []const u8) void { - const n = @min(uname.len, name_max); + const n = @min(uname.len, username_capacity); @memcpy(s.uname[0..n], uname[0..n]); s.uname_len = @intCast(n); } - // -- the transport seam ---------------------------------------------- - - /// Offer parked requests back, one per call, in arrival order. Call in - /// a loop until null, once per frame, BEFORE `next()`: the null both - /// ends the round and resets it, so every parked request gets exactly - /// one attempt per frame and a permanently blocked reader cannot - /// starve the others. `src/fs_service.zig:196-208` is the contract and - /// `src/fuse.zig:1166` is the other implementation of it. pub fn retry(s: *Self) ?fs.Req { var best: ?usize = null; for (&s.slots, 0..) |*sl, i| { @@ -2378,40 +1431,20 @@ pub fn Server(comptime fs: type) type { for (&s.slots) |*sl| sl.retried = false; return null; }; - // No room for the answer is the end of the round too, and it must - // reset it: leaving the flags set would make the next frame skip - // the requests this one never reached. if (!s.hasRoom()) { for (&s.slots) |*sl| sl.retried = false; return null; } s.slots[i].retried = true; - // In flight again: `reply` re-parks it if the core still has - // nothing to say. s.slots[i].parked = false; return s.slots[i].req; } - /// The next request off the wire, or null when there is nothing more to - /// do with the bytes pushed so far. Call in a loop until null. - /// - /// ONE 9P MESSAGE IS NOT ONE REQUEST, which is the whole reason this is - /// a state machine: a three-element `Twalk` is three lookups, a - /// `Topen` with `OTRUNC` is a truncate and then an open, and a - /// `Tversion` is none at all. So this pump decodes a message when it - /// needs one, hands out its steps as the core answers them, and - /// answers null only when the input is exhausted, the queue is full, or - /// the core is holding a step. pub fn next(s: *Self) ?fs.Req { while (true) { if (s.job.kind != .none) { - // A step is out with the core; the caller owes us a - // `reply` before there is anything else to ask. if (s.job.req_tag != 0) return null; if (s.stepJob()) |req| return req; - // The job answered itself — a walk that finished, an error - // — and `stepJob` cleared it. Round again for the next - // message. assert(s.job.kind == .none); continue; } @@ -2421,28 +1454,11 @@ pub fn Server(comptime fs: type) type { } } - /// Answer one request: queue the 9P reply it completes, advance the - /// message it is a step of, or park it. `bytes` is the payload the - /// core resolved and is borrowed for the duration of this call only — - /// the same rule `src/fs_service.zig:224-229` states for the FUSE - /// transport. pub fn reply(s: *Self, r: *const fs.Reply, bytes: []const u8) void { if (s.job.kind != .none and s.job.req_tag == r.tag) return s.jobReply(r, bytes); if (s.findSlot(r.tag)) |i| return s.slotReply(i, r, bytes); - // An orphan's release, a park `Tversion` abandoned, or a request - // `Tflush` already answered. Nothing to say and nobody to say it - // to; `fuse.zig:1189` drops the same case for the same reason. } - /// A `release` nobody is waiting for: the fid it belonged to is gone - /// (the connection dropped, or `Tversion` reset it) but the core's - /// open is not. - /// - /// The slot is freed HERE rather than when the answer lands, because - /// nothing in the answer is wanted and `reply` already ignores a tag it - /// no longer holds. That also means a release the core parks is - /// dropped, which is the same trade `fuse.zig` makes for a write: a - /// release is a transaction in this design and does not block. fn orphan(s: *Self) ?fs.Req { for (&s.fids) |*f| { if (!f.used or !f.orphan) continue; @@ -2459,23 +1475,11 @@ pub fn Server(comptime fs: type) type { return null; } - /// Decode the message at the head of `in` and start serving it. False - /// when there is not a whole one there yet. - /// - /// The frame stays in `in` for as long as the message is being served, - /// because every string in a decoded `Msg` points into it. The `defer` - /// is what makes that airtight: a message that answered itself here - /// releases the frame immediately, and one that became a job hands the - /// frame to the job, which releases it in `finishJob` or copies what it - /// needs in `parkJob`. fn startFrame(s: *Self) bool { assert(s.job.kind == .none); assert(s.frame == 0); if (s.dead) return false; const len = frameLen(s.in[0..s.in_len]) orelse return false; - // A `size` no encoder produced, or one this connection could never - // buffer: either way the stream is not 9P and waiting for more of - // it is waiting forever. if (len < header_len or len > s.in.len) { s.dead = true; return false; @@ -2485,22 +1489,13 @@ pub fn Server(comptime fs: type) type { defer if (s.job.kind == .none) s.dropFrame(); const got = decode(s.in[0..len]) catch { - // The tag sits at a fixed offset and survives every way the - // body can be wrong, so the client still gets an answer rather - // than a hang. `len >= header_len` was checked above. s.fail(std.mem.readInt(u16, s.in[5..7], .little), e_botch); return true; }; - // A server reads T-messages. An R-message here is a client on the - // wrong end of the connection, or the double-role link - // docs/9p.typ §7 tells us not to build. if (!isT(got.msg.msgType())) { s.fail(got.tag, e_botch); return true; } - // «The client must communicate the version before any other - // messages» — and until it has, there is no msize to bound - // anything by. if (s.msize == 0 and got.msg != .tversion) { s.fail(got.tag, e_botch); return true; @@ -2513,10 +1508,6 @@ pub fn Server(comptime fs: type) type { return true; } - /// Release the served frame and slide the rest of the input down. The - /// move is one message long and happens once per message; the - /// alternative is a ring buffer, which would mean a decoded `Msg` - /// could straddle the wrap and no longer be one slice. fn dropFrame(s: *Self) void { assert(s.frame != 0); assert(s.frame <= s.in_len); @@ -2526,27 +1517,9 @@ pub fn Server(comptime fs: type) type { s.frame = 0; } - // -- the messages ---------------------------------------------------- - - /// One T-message onto its handler. Every message either answers itself - /// here or becomes `job`. fn dispatch(s: *Self, got: Decoded) void { switch (got.msg) { .tversion => |m| s.version(got.tag, m.msize, m.version), - // REFUSED, all three, and each for its own reason. - // - // `Tauth`: there is no authentication here and there is not - // going to be one in this file. The socket's permissions are - // the protection and a network is tunnelled (docs/9p.typ §10). - // - // `Tcreate` and `Tremove`: the shape of this tree follows the - // pane list, so there is nothing in it for a client to make or - // unmake. The one place a client DOES create something is - // `new/`, where walking to a name is what creates a pane - // (`acmefs.zig:901-919`) — so the capability is there and it - // is not spelled `Tcreate`. That answers the open question in - // `docs/registry.typ` `9P-18`, and it takes most of `ad`'s - // shipped-and-fixed bug list off the table with it. .tauth => s.fail(got.tag, e_no_auth), .tcreate => s.fail(got.tag, e_perm), .tattach => |m| s.attach(got.tag, m.fid, m.uname, m.aname), @@ -2556,80 +1529,46 @@ pub fn Server(comptime fs: type) type { .tread => |m| s.read(got.tag, m.fid, m.offset, m.count), .twrite => |m| s.write(got, m.fid, m.offset, m.data.len), .tclunk => |m| s.clunk(got.tag, m.fid, .clunk), - // A remove clunks the fid too — see `clunk` — which is the - // half of `remove(5)` that is easy to miss. .tremove => |m| s.clunk(got.tag, m.fid, .remove), .tstat => |m| s.stat(got.tag, m.fid), .twstat => |m| s.wstat(got.tag, m.fid, m.stat), - // The R-variants, which `startFrame` already refused by - // parity. Answered rather than `unreachable`, because the cost - // of being wrong about that is a panic in a server. else => s.fail(got.tag, e_botch), } } - /// `Tversion`: the msize handshake, and a connection reset. fn version(s: *Self, tag: u16, want: u32, ver: []const u8) void { - // Three ceilings and the smallest wins: what the client will - // accept, what one input buffer holds, and half of what the output - // queue holds (`Options.out`). const cap: u32 = @intCast(@min(s.in.len, s.out.len / 2)); const m = @min(want, cap); if (m < msize_min) return s.fail(tag, e_small_msize); - // u9fs `rversion`: any version string that STARTS with "9P" is - // answered "9P2000", which is how a `.u` or `.L` client is told to - // fall back to the base protocol. Anything else has no dialect in - // common with us, and that is a SUCCESSFUL `Rversion` carrying the - // literal "unknown" rather than an `Rerror`. const known = std.mem.startsWith(u8, ver, "9P"); s.reset(); - // The msize only becomes real once a version is agreed: after - // "unknown" the client must negotiate again, and `startFrame` - // serves nothing else until it does. s.msize = if (known) m else 0; s.emit(tag, .{ .rversion = .{ .msize = m, .version = if (known) "9P2000" else "unknown" } }); } fn attach(s: *Self, tag: u16, fid: u32, uname: []const u8, aname: []const u8) void { - // No `aname`. There is one tree here and it has no name; a client - // that asked for another one is told so rather than handed this. if (aname.len != 0) return s.fail(tag, e_no_tree); if (fid == nofid) return s.fail(tag, e_unknown_fid); if (s.findFid(fid) != null) return s.fail(tag, e_fid_in_use); if (s.freeFid() == null) return s.fail(tag, e_too_many_fids); s.setUname(uname); - // The root's attributes come from the core like every other node's. - // Its node id is the only thing we were told (see `root`). s.job = .{ .kind = .attach, .tag = tag, .fid = fid, .node = s.root }; } fn walk(s: *Self, got: Decoded, fid: u32, newfid: u32, nwname: u8) void { const tag = got.tag; const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); - // «must not have been opened for I/O» — walk(5). The fid IS the - // open, so a walk would move the file out from under it. if (s.fids[i].open) return s.fail(tag, e_bad_use); if (newfid == nofid) return s.fail(tag, e_unknown_fid); if (newfid != fid) { if (s.findFid(newfid) != null) return s.fail(tag, e_fid_in_use); - // Checked BEFORE any lookup, because a lookup under `new/` - // creates a pane and a walk that then failed for want of a fid - // slot would leave one behind. if (s.freeFid() == null) return s.fail(tag, e_too_many_fids); } if (nwname == 0) { - // THE CLONE. No names, no lookups, no qids: `Rwalk` with - // `nwqid == 0`, and it is a success — which is exactly why a - // failure on the first element may not be spelled that way. if (newfid != fid) { const j = s.freeFid().?; s.fids[j] = s.fids[i]; s.fids[j].fid = newfid; - // A clone shares the file and NOT the directory cursor: - // two fids on one directory each keep their own place, - // which is what a duplicated descriptor means everywhere - // else. The open state is not shared either, and cannot - // be — an open fid was refused above. s.fids[j].diroff = 0; s.fids[j].dirindex = 0; } @@ -2656,22 +1595,13 @@ pub fn Server(comptime fs: type) type { const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); const f = &s.fids[i]; if (f.open) return s.fail(tag, e_already_open); - // Nothing in a generated tree can be removed, so nothing in it can - // be opened remove-on-close either. if (mode & orclose != 0) return s.fail(tag, e_perm); const rw = mode & 3; if (rw == oexec) return s.fail(tag, e_perm); - // A directory is read, and only read: 9P has no other verb for one, - // and truncating a pane list is not a thing to mean. if (f.dir and (rw != oread or mode & otrunc != 0)) return s.fail(tag, e_perm); var need: u16 = 0; if (rw == oread or rw == ordwr) need |= 0o400; if (rw == owrite or rw == ordwr or mode & otrunc != 0) need |= 0o200; - // THE PERMISSION CHECK IS OURS. Under FUSE the kernel does it, - // against the mode a `getattr` reported, and `acmefs.open` never - // sees a mode at all (`acmefs.zig:1023-1031`). Over 9P there is - // nobody above us, so this is what stops `errors` and `wrsel` — - // write-only in acme's own dirtab — from being readable. if (f.perm & need != need) return s.fail(tag, e_perm); s.job = .{ .kind = .open, .tag = tag, .fid = fid, .omode = mode }; } @@ -2679,28 +1609,12 @@ pub fn Server(comptime fs: type) type { fn read(s: *Self, tag: u16, fid: u32, offset: u64, count: u32) void { const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); const f = &s.fids[i]; - // The two conditions `u9fs.c:755-758` refuses, and the same - // answer: a fid that was never opened, or one opened write-only. if (!f.open or (f.omode & 3) == owrite) return s.fail(tag, e_bad_use); - // THE CLAMP, `min(count, msize - 11)`. An `Rread` longer than the - // count asked for is a hard `-EIO` in Linux rather than a - // truncation (`net/9p/client.c:1475-1479`, `9P-17`), and one - // longer than the msize is a message the client cannot read at - // all. Eleven is `Rread`'s header: `size[4] type[1] tag[2] - // count[4]`. It is applied to the request as well as to the - // answer, so the core is never asked to produce bytes that would - // have to be thrown away. const want = @min(count, s.msize - header_len - 4); if (!f.dir) { s.job = .{ .kind = .read, .tag = tag, .fid = fid, .offset = offset, .count = want }; return; } - // THE DIRECTORY RULE: offset zero, or exactly where the last read - // ended, and nothing else (`u9fs.c:760-769`, `lib9p/srv.c:473`). A - // client that seeks inside a directory is refused rather than - // served a listing that tears — which is the bug `ad` has, where an - // arbitrary offset that happens to land on an entry boundary is - // silently accepted (`9P-5`). if (offset != f.diroff) { if (offset != 0) return s.fail(tag, e_bad_offset); f.diroff = 0; @@ -2727,10 +1641,6 @@ pub fn Server(comptime fs: type) type { fn clunk(s: *Self, tag: u16, fid: u32, kind: Kind) void { assert(kind == .clunk or kind == .remove); const i = s.findFid(fid) orelse return s.fail(tag, e_unknown_fid); - // An open fid owes the core a `release` before it goes. That is - // what decrements a pane's `event` reader count, and losing it - // leaves the editor reporting button actions to a script that has - // gone (`acmefs.zig:1069-1093`). if (s.fids[i].open) { s.job = .{ .kind = kind, .tag = tag, .fid = fid }; return; @@ -2746,61 +1656,28 @@ pub fn Server(comptime fs: type) type { fn wstat(s: *Self, tag: u16, fid: u32, st: Stat) void { if (s.findFid(fid) == null) return s.fail(tag, e_unknown_fid); - // The sentinels `stat(5)` specifies: an empty string and an - // all-ones integer mean "do not touch". The codec above carries - // them and has no opinion; deciding is this file's job. - if (st.name.len != 0) return s.fail(tag, e_wstat); + if (st.type != std.math.maxInt(u16) or st.dev != std.math.maxInt(u32) or + st.qid.type != std.math.maxInt(u8) or st.qid.version != std.math.maxInt(u32) or + st.qid.path != std.math.maxInt(u64) or st.mode != std.math.maxInt(u32) or + st.atime != std.math.maxInt(u32) or st.mtime != std.math.maxInt(u32) or + st.name.len != 0 or st.uid.len != 0 or st.gid.len != 0 or st.muid.len != 0) + return s.fail(tag, e_wstat); if (st.length == std.math.maxInt(u64)) { - // Nothing left that we honour. Mode, owner, group and the two - // times are ACCEPTED AND IGNORED, which is what a filesystem - // of live editor state has to do with them - // (`acmefs.zig:1096-1104`): refusing would make `touch` and - // `chmod` fail on a tree where they mean nothing anyway. s.emit(tag, .rwstat); return; } - // A length that is neither the sentinel nor zero. The core honours - // exactly one value, so name it — and this string is one Linux - // already knows, so `truncate` gets EPERM rather than 526. if (st.length != 0) return s.fail(tag, e_trunc_only); - // ...and zero IS the truncate, which is the same `Req.truncate` - // that `Topen` with `OTRUNC` produces (`FIX-1`). s.job = .{ .kind = .wstat, .tag = tag, .fid = fid }; } - /// `Tflush`: a park-table lookup, and THE ORDER IS THE POINT. - /// - /// The original is answered first and the `Rflush` second. That is what - /// `lib9p/srv.c:241-266` does with its chained flush list, and what - /// `srv.c:810-827` does when the original finally responds: write the - /// original's reply, then respond to every flush waiting on it. A - /// client that sees `Rflush` may reuse the tag, so a reply arriving - /// after it would be a reply to whatever the tag names NEXT. - /// - /// `docs/registry.typ` `9P-16` says this is where we beat the prior - /// art, and the reason is structural rather than clever: the park table - /// is already keyed per outstanding request, so this is a lookup and - /// two replies. `ad` gets the ordering right in thirty-nine lines and - /// then defaults its filesystem's `flush` hook to doing nothing, so a - /// client flushing a blocked `event` read waits for an unrelated editor - /// event to arrive. There is no hook here to forget to implement. fn flush(s: *Self, tag: u16, oldtag: u16) void { if (s.findTag(oldtag)) |i| { - // EINTR and drop it, which is exactly what `fuse.zig:1334-1341` - // answers a `FUSE_INTERRUPT` naming a parked request. s.fail(s.slots[i].tag, e_interrupted); s.slots[i] = .{}; } - // A tag we do not hold was already answered or never existed. - // `Rflush` either way: after it the client may reuse the tag, and - // that is the only promise `flush(5)` makes. s.emit(tag, .rflush); } - // -- steps and answers ----------------------------------------------- - - /// Record the step being handed to the core, so that a park has - /// something to copy and `reply` has something to match. fn ask(s: *Self, req: fs.Req) fs.Req { assert(req.tag != 0); s.job.req = req; @@ -2808,9 +1685,6 @@ pub fn Server(comptime fs: type) type { return req; } - /// The fid the message in flight names. Null cannot happen — nothing - /// else runs while a job does — and is answered rather than asserted, - /// because the cost of being wrong is a corrupted table. fn jobFid(s: *Self) ?*Fid { const i = s.findFid(s.job.fid) orelse { s.fail(s.job.tag, e_unknown_fid); @@ -2820,8 +1694,6 @@ pub fn Server(comptime fs: type) type { return &s.fids[i]; } - /// The next core request the message in flight needs, or null when it - /// has just answered itself. fn stepJob(s: *Self) ?fs.Req { const j = &s.job; assert(j.kind != .none); @@ -2832,7 +1704,6 @@ pub fn Server(comptime fs: type) type { .walk => return s.stepWalk(), .open => { const f = s.jobFid() orelse return null; - // `OTRUNC` is a truncate and THEN an open, in that order. if (j.step == 0 and j.omode & otrunc != 0) return s.ask(.{ .tag = s.tick(), .op = .setattr, @@ -2854,10 +1725,6 @@ pub fn Server(comptime fs: type) type { }, .readdir => { const f = s.jobFid() orelse return null; - // THE COORDINATE CHANGE. 9P counts bytes and `acmefs` - // counts entries (`acmefs.zig:972`), so the request carries - // the entry index this fid's byte cursor stands at, and - // `emitDirRead` advances both. return s.ask(.{ .tag = s.tick(), .op = .readdir, @@ -2899,42 +1766,24 @@ pub fn Server(comptime fs: type) type { } } - /// One walk element at a time, and the local ones without asking. fn stepWalk(s: *Self) ?fs.Req { const j = &s.job; while (j.step < j.nwname) { const name = j.msg.twalk.wname[j.step]; - // A name the fid could not hold cannot be a name in this tree, - // and refusing it on its length is what keeps `Rstat` honest. - if (name.len > name_max) { + if (name.len > name_capacity) { s.stopWalk(e_illegal_name); return null; } - // `.` is the fid where it already stands, and costs nothing. if (std.mem.eql(u8, name, ".")) { j.wqid[j.nwqid] = qidOf(j.node, j.dir); j.nwqid += 1; j.step += 1; continue; } - if (std.mem.eql(u8, name, "..")) { - const p = parentOf(j.node, s.root, &j.name); - j.name_len = p.name_len; - j.node = p.node; - // WHICH node the parent is, is ours to work out; what it - // LOOKS like is not. A `getattr` keeps `perm`, `dir` and - // the qid the core's answer rather than this file's - // invention, and reports ENOENT if the pane closed - // underneath us. - return s.ask(.{ .tag = s.tick(), .op = .getattr, .node = p.node }); - } @memcpy(j.name[0..name.len], name); j.name_len = @intCast(name.len); return s.ask(.{ .tag = s.tick(), .op = .lookup, .node = j.node, .data = name }); } - // Every element resolved, so `newfid` is bound — and only now. A - // partial walk binds NOTHING, which is `ad`'s «new_fid is only - // bound when all elements were walked successfully» and the spec's. const dst = pick: { if (j.newfid == j.fid) break :pick s.findFid(j.fid) orelse { s.fail(j.tag, e_unknown_fid); @@ -2961,16 +1810,6 @@ pub fn Server(comptime fs: type) type { return null; } - /// A walk that could not finish, and THE SCAR that says how to answer - /// it: «Spec: first element failure must be Rerror, not Rwalk with zero - /// qids» — `ad/crates/ninep/src/sansio/server.rs:335-338`, left in - /// their source after they shipped it the other way. Zero qids already - /// means the clone, so it cannot also mean a failure. - /// - /// A failure at any LATER element is a successful short `Rwalk`, and - /// the client is expected to notice that it got fewer qids than it - /// asked for. It gets no error string at all, which is the protocol's - /// choice and not ours. fn stopWalk(s: *Self, ename: []const u8) void { const j = &s.job; if (j.nwqid == 0) @@ -2985,18 +1824,12 @@ pub fn Server(comptime fs: type) type { if (s.frame != 0) s.dropFrame(); } - /// The core answered a step of the message in flight. fn jobReply(s: *Self, r: *const fs.Reply, bytes: []const u8) void { const j = &s.job; assert(j.kind != .none); assert(j.req_tag == r.tag); j.req_tag = 0; - // A CLUNK CANNOT FAIL. «even if the clunk fails, the fid is no - // longer valid» — clunk(5) — and `remove(5)` says the same of - // remove, so the core's answer to the release is not consulted at - // all. That also means a release the core tried to park is dropped - // rather than leaving behind a fid the client can no longer reach. if (j.kind == .clunk or j.kind == .remove) { s.dropFid(j.fid); if (j.kind == .remove) s.fail(j.tag, e_perm) else s.emit(j.tag, .rclunk); @@ -3028,8 +1861,6 @@ pub fn Server(comptime fs: type) type { .dir = r.attr.dir, .perm = r.attr.mode, }; - // The root's name is "/" — one of the bugs `ad` shipped - // and then fixed (`9P-18`, commit `64f2f4b`). s.fids[i].name[0] = '/'; s.fids[i].name_len = 1; s.emit(j.tag, .{ .rattach = .{ .qid = qidOf(node, r.attr.dir) } }); @@ -3037,17 +1868,20 @@ pub fn Server(comptime fs: type) type { }, .walk => { if (r.attr.node != 0) j.node = r.attr.node; + if (comptime @hasField(@TypeOf(r.attr), "name")) { + if (r.attr.name.len != 0) { + j.name_len = @intCast(@min(r.attr.name.len, j.name.len)); + @memcpy(j.name[0..j.name_len], r.attr.name[0..j.name_len]); + } + } j.dir = r.attr.dir; j.perm = r.attr.mode; j.wqid[j.nwqid] = qidOf(j.node, j.dir); j.nwqid += 1; j.step += 1; - // The job STAYS: `next()` asks `stepWalk` for the next - // element, or lets it bind the fid and answer. }, .open => { if (j.step == 0 and j.omode & otrunc != 0) { - // The truncate landed; the open is the next step. j.step = 1; return; } @@ -3055,12 +1889,8 @@ pub fn Server(comptime fs: type) type { f.open = true; f.omode = j.omode; f.handle = r.handle; - // A fresh open starts a directory at the beginning. f.diroff = 0; f.dirindex = 0; - // `iounit` is the largest atomic read or write: one message - // less the slack `fcall.h:72` has reserved for a `Twrite` - // header for thirty years. s.emit(j.tag, .{ .ropen = .{ .qid = qidOf(f.node, f.dir), .iounit = s.msize - iohdrsz, @@ -3068,10 +1898,6 @@ pub fn Server(comptime fs: type) type { s.finishJob(); }, .read => { - // Clamped a second time, against the bytes that actually - // came back: the request already carried the count, and a - // core that answered with more would otherwise become an - // `-EIO` in the client rather than a bug here. s.emit(j.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, j.count)] } }); s.finishJob(); }, @@ -3080,21 +1906,18 @@ pub fn Server(comptime fs: type) type { s.finishJob(); }, .write => { - // The core's own count and not the request's: `data` - // refusing a partial grapheme is a real short write, and - // claiming the whole request would tell the writer that - // its trailing bytes landed when they did not. s.emit(j.tag, .{ .rwrite = .{ .count = @min(r.written, j.count) } }); s.finishJob(); }, .stat => { const f = s.jobFid() orelse return; - // The core is authoritative about size and mode, and the - // fid's cache follows: `body` grows between stats, and - // `Topen` is checked against `perm`. f.perm = r.attr.mode; f.dir = r.attr.dir; - s.emit(j.tag, .{ .rstat = .{ .stat = s.statOf(f, r.attr) } }); + const response: Msg = .{ .rstat = .{ .stat = s.statOf(f, r.attr) } }; + if ((totalLen(response) catch unreachable) > s.msize) + s.fail(j.tag, e_small_msize) + else + s.emit(j.tag, response); s.finishJob(); }, .wstat => { @@ -3104,18 +1927,8 @@ pub fn Server(comptime fs: type) type { } } - /// The core said `.again`: nothing consumed, ask me later. The request - /// moves into a park slot and the 9P tag goes with it, so the client - /// hears nothing at all until the core has something to say — which is - /// what makes a blocking `event` read work on a single-threaded core - /// with no waiter list anywhere. fn parkJob(s: *Self) void { const j = &s.job; - // Only these three can park, and only because the state a retry - // needs is scalars. A walk cannot: its names borrow the input - // buffer, which the next message overwrites. `e_again` is honest - // (the client may retry) and by construction unreachable — the - // core parks reads of `event` and nothing else. switch (j.kind) { .read, .readdir, .write => {}, else => { @@ -3125,8 +1938,6 @@ pub fn Server(comptime fs: type) type { }, } const i = s.freeSlot() orelse { - // Overflow is a refusal, not a queue: thirty-two blocked - // readers is thirty-two scripts watching one session. s.fail(j.tag, e_again); s.finishJob(); return; @@ -3144,8 +1955,6 @@ pub fn Server(comptime fs: type) type { }; if (j.req.data.len != 0) { if (j.req.data.len > park_data_max) { - // A payload too large to copy would go on borrowing the - // input buffer, so parking it would park a dangling slice. sl.* = .{}; s.fail(j.tag, e_again); s.finishJob(); @@ -3155,12 +1964,9 @@ pub fn Server(comptime fs: type) type { sl.copied = true; sl.req.data = sl.data[0..j.req.data.len]; } - // The frame is nobody's now: everything the retry needs has been - // copied, so the next message may take its place. s.finishJob(); } - /// The core answered a request that had been parked. fn slotReply(s: *Self, i: usize, r: *const fs.Reply, bytes: []const u8) void { const sl = &s.slots[i]; if (r.status == .again) { @@ -3176,25 +1982,11 @@ pub fn Server(comptime fs: type) type { .read => s.emit(sl.tag, .{ .rread = .{ .data = bytes[0..@min(bytes.len, sl.count)] } }), .readdir => s.emitDirRead(sl.tag, sl.fid, bytes, sl.count), .write => s.emit(sl.tag, .{ .rwrite = .{ .count = @min(r.written, sl.count) } }), - // `parkJob` admits no other kind. else => s.fail(sl.tag, e_botch), } sl.* = .{}; } - /// A directory read: `acmefs`'s staged entries become 9P `stat` - /// records, in place, and the fid's cursor advances by exactly what - /// was sent. - /// - /// THE ONE ENTRY THAT DID NOT FIT needs no buffer here, and that is - /// worth saying because every reference server has one: u9fs caches a - /// `dirent` per fid «for when convD2M fails» (`u9fs.c:780`) because - /// `readdir(3)` has already consumed it. `acmefs` re-stages the whole - /// listing from an entry index on every call and says why — - /// «re-staging from scratch on every call is what makes a partially - /// consumed answer safe to ask for again at a higher cookie» - /// (`acmefs.zig:1013-1016`) — so an entry that does not fit is simply - /// not counted, and the next read asks for it by index. fn emitDirRead(s: *Self, tag: u16, fid: u32, staging: []const u8, count: u32) void { const buf = s.out[s.out_len..]; assert(buf.len > header_len + 4); @@ -3203,9 +1995,8 @@ pub fn Server(comptime fs: type) type { var n: usize = header_len + 4; var entries: u32 = 0; var i: usize = 0; - // `node[8] dir[1] namelen[1] name[]`, repeated — `acmefs.zig:942`. while (i + 10 <= staging.len) { - const nlen = staging[i + 9]; + const nlen: usize = staging[i + 9]; if (i + 10 + nlen > staging.len) break; const dir = staging[i + 8] != 0; const rec: Stat = .{ @@ -3222,60 +2013,32 @@ pub fn Server(comptime fs: type) type { .muid = who, }; const size = @as(usize, rec.size() catch break) + 2; - // WHOLE RECORDS ONLY. `read(5)`: a directory read returns an - // integral number of entries, so the first one that does not - // fit ends the reply and the cursor stops in front of it. if (n - header_len - 4 + size > cap) break; _ = rec.encode(buf[n..]) catch break; n += size; entries += 1; i += 10 + nlen; } - // A count that cannot hold the FIRST entry is refused rather than - // answered with zero bytes, because zero bytes is end of directory - // and a client that believes it stops asking. `entries == 0` with - // nothing staged is the real end. if (entries == 0 and staging.len != 0) return s.fail(tag, e_count_small); const payload: u32 = @intCast(n - header_len - 4); - // The header goes on LAST, over bytes reserved for it, because the - // payload's length is only known once the entries are encoded — - // `u9fs.c:775-806` builds it the same way and for the same reason. - // Written by hand rather than through `encode`, which would want - // the payload contiguous somewhere else first, and this file will - // not carry a third msize buffer to make that true. The test "a - // directory read is whole stat records" decodes the result with - // `decode`, which is what keeps these four lines honest. comptime assert(header_len == 7); std.mem.writeInt(u32, buf[0..4], @intCast(n), .little); buf[4] = @intFromEnum(Type.rread); std.mem.writeInt(u16, buf[5..7], tag, .little); std.mem.writeInt(u32, buf[7..11], payload, .little); s.out_len += n; - // BOTH cursors, together, or the next read is refused: bytes for - // the client's offset rule, entries for `acmefs`'s index. if (s.findFid(fid)) |k| { s.fids[k].diroff += payload; s.fids[k].dirindex += entries; } } - /// One `stat` record for a file the core has just described. - /// - /// `type` and `dev` are Plan 9 kernel device identifiers, meaningless - /// off Plan 9, and zero — as u9fs sends them. `atime` and `mtime` are - /// zero because this tree has no times to report and the FUSE - /// transport already reports none (`fuse.zig:1544-1546`); an invented - /// time is one `make` would believe. The three name fields are whoever - /// attached: the tree is synthetic and has exactly one owner. fn statOf(s: *const Self, f: *const Fid, a: fs.Reply.Attr) Stat { const who = s.uname[0..s.uname_len]; return .{ .type = 0, .dev = 0, .qid = qidOf(if (a.node != 0) a.node else f.node, a.dir), - // The high bits are the type and the low nine are the - // permission: `dmdir` is `qtdir` shifted up 24, which the - // codec's last test asserts rather than assumes. .mode = (if (a.dir) dmdir else 0) | @as(u32, a.mode), .atime = 0, .mtime = 0, @@ -3289,28 +2052,8 @@ pub fn Server(comptime fs: type) type { }; } -// --------------------------------------------------------------------------- -// server tests -// --------------------------------------------------------------------------- -// -// Driven with BYTE ARRAYS and a STUB FILESYSTEM, so there is no `Pardes` here -// and no transport either: `push` takes encoded messages, `pump` is -// `fs_service.drain` written out, and `reap` decodes what came back with the -// codec above. A test that fails is a message a real client would have been -// sent, byte for byte. - -/// Everything `Server` asks of a filesystem, plus a tree small enough to check -/// by eye. The three types are `acmefs`'s ABI verbatim — that is the whole -/// contract, and `Server(acmefs)` is the instantiation that matters — so this -/// is a MIRROR and not a redefinition: a field that drifts is a compile error -/// the moment the real adapter is built. -/// -/// THE NODE IDS ARE `acmefs.Node`'s PACKING, `{ file: u4, serial: u60 }`, and -/// they have to be: `parentOf` reads them. So pane 1's directory is `1 << 4`, -/// its `body` is that plus `PaneFile.body` (2), and the top-level files are -/// `TopFile`'s own 1..4 with a zero serial. const StubFs = struct { - pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; + pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir }; pub const Status = enum(u8) { ok, again, err }; pub const Req = struct { @@ -3333,6 +2076,7 @@ const StubFs = struct { written: u32 = 0, pub const Attr = struct { + name: []const u8 = "", node: u64 = 0, dir: bool = false, size: u64 = 0, @@ -3342,9 +2086,6 @@ const StubFs = struct { const Entry = struct { node: u64, parent: u64, name: []const u8, dir: bool, mode: u16 }; - /// `acmefs`'s tree, cut down: the root's four names, two panes, and six of - /// a pane's files including the two that matter most here — `event`, which - /// blocks, and `errors`, which is write-only. const tree = [_]Entry{ .{ .node = 1, .parent = 1, .name = "/", .dir = true, .mode = 0o500 }, .{ .node = 2, .parent = 1, .name = "index", .dir = false, .mode = 0o400 }, @@ -3365,11 +2106,8 @@ const StubFs = struct { const event_node = 22; body: []const u8 = "hello, body\n", - /// `index`, and long enough that a read of it has to be clamped. filler: [1024]u8 = @splat('x'), - /// One event record, or nothing — which is `Status.again`. event: ?[]const u8 = null, - /// Set to make every write park, so the copy into a slot is exercised. park_writes: bool = false, releases: u32 = 0, calls: u32 = 0, @@ -3401,11 +2139,9 @@ const StubFs = struct { } fn attrOf(st: *const StubFs, e: Entry) Reply.Attr { - return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) }; + return .{ .name = e.name, .node = e.node, .dir = e.dir, .mode = e.mode, .size = st.sizeOf(e.node) }; } - /// `acmefs`'s staging format, which is what the 9P server decodes: - /// `node[8] dir[1] namelen[1] name[]`, repeated, from an ENTRY INDEX. fn stageDir(st: *StubFs, node: u64, skip: u64) []const u8 { var n: usize = 0; var seen: u64 = 0; @@ -3430,6 +2166,8 @@ const StubFs = struct { const i = find(req.node) orelse return fail; switch (req.op) { .lookup => { + if (std.mem.eql(u8, req.data, "..")) + return .{ .reply = .{ .tag = req.tag, .attr = st.attrOf(tree[find(tree[i].parent).?]) } }; for (tree) |e| { if (e.parent != req.node or e.node == req.node) continue; if (!std.mem.eql(u8, e.name, req.data)) continue; @@ -3452,8 +2190,6 @@ const StubFs = struct { return .{ .reply = .{ .tag = req.tag }, .bytes = st.stageDir(req.node, req.off) }; }, .read => { - // `event`: one record per read, and `.again` when there is - // none — `acmefs.zig:1358-1367` exactly. if (req.node == event_node) { const rec = st.event orelse return .{ .reply = .{ .tag = req.tag, .status = .again } }; st.event = null; @@ -3471,22 +2207,18 @@ const StubFs = struct { st.writes_len += n; return .{ .reply = .{ .tag = req.tag, .written = @intCast(n) } }; }, - .statfs => return .{ .reply = .{ .tag = req.tag } }, } } }; -const Srv = Server(StubFs); +const Srv = Server(StubFs, max_fids); -/// One connection: two buffers, a stub filesystem and the server between them. const Harness = struct { in: [4096]u8 = undefined, out: [8192]u8 = undefined, fsys: StubFs = .{}, srv: Srv = undefined, - /// The buffers are fields, so the server can only be built once the - /// harness has an address. fn start(h: *Harness) void { h.srv = Srv.init(.{ .in = &h.in, .out = &h.out, .root = 1 }); } @@ -3496,11 +2228,6 @@ const Harness = struct { h.srv.reply(&a.reply, a.bytes); } - /// THE TRANSPORT CONTRACT, in the shape `src/fs_service.zig:209-222` - /// requires it: every parked request offered once, then everything the - /// wire has, both loops to null. Written out rather than imported — - /// `fs_service` is `pardes.zig` deep — and writing it out is how these - /// tests document what they are testing against. fn pump(h: *Harness) void { while (h.srv.retry()) |req| h.answer(req); while (h.srv.next()) |req| h.answer(req); @@ -3513,8 +2240,6 @@ const Harness = struct { h.pump(); } - /// One reply off the queue. Borrows the out buffer, so a caller checks it - /// before sending anything else. fn reap(h: *Harness) !Decoded { const out = h.srv.output(); const len = frameLen(out) orelse return error.NoReply; @@ -3528,8 +2253,6 @@ const Harness = struct { try testing.expectEqual(@as(usize, 0), h.srv.output().len); } - /// `Tversion` and `Tattach`, which every test but the handshake ones want, - /// leaving the root on fid 0. fn handshake(h: *Harness, msize: u32) !void { h.start(); try h.send(notag, .{ .tversion = .{ .msize = msize, .version = "9P2000" } }); @@ -3540,7 +2263,6 @@ const Harness = struct { try testing.expectEqual(@as(u64, 1), a.msg.rattach.qid.path); } - /// A walk from the root to one name, landing on `newfid`. fn walkTo(h: *Harness, tag: u16, newfid: u32, list: []const []const u8) !Decoded { try h.send(tag, .{ .twalk = .{ .fid = 0, @@ -3558,9 +2280,6 @@ fn wnames(list: []const []const u8) [max_welem][]const u8 { return out; } -/// The names in a directory read, decoded as whole `stat` records. Which is -/// also the proof that `emitDirRead`'s hand-written header is right: this goes -/// through `Stat.decode`, which refuses anything that does not add up. fn dirNames(data: []const u8, out: [][]const u8) !usize { var n: usize = 0; var i: usize = 0; @@ -3578,40 +2297,27 @@ test "9p server: the version handshake clamps, falls back, and refuses" { var h: Harness = .{}; h.start(); - // A client offering a megabyte gets what the buffers hold: one input - // buffer, or half the output queue, whichever is smaller. try h.send(notag, .{ .tversion = .{ .msize = 1 << 20, .version = "9P2000" } }); var got = try h.reap(); try testing.expectEqual(notag, got.tag); try testing.expectEqual(@as(u32, 4096), got.msg.rversion.msize); try testing.expectEqualStrings("9P2000", got.msg.rversion.version); - // BELOW LINUX'S FLOOR IS FINE. 4096 is the kernel's number and nobody - // else's: Plan 9's devmnt, plan9port's `9p` and our own client all accept - // 512, and refusing it would cost the board a kilobyte for nothing. try h.send(notag, .{ .tversion = .{ .msize = 512, .version = "9P2000" } }); got = try h.reap(); try testing.expectEqual(@as(u32, 512), got.msg.rversion.msize); - // A `.u` client is told to fall back rather than refused: u9fs answers - // "9P2000" to any version starting with "9P". try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000.u" } }); got = try h.reap(); try testing.expectEqualStrings("9P2000", got.msg.rversion.version); - // Something that is not 9P at all: the literal "unknown", in a SUCCESSFUL - // Rversion and not an Rerror. try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "TCP/IP" } }); got = try h.reap(); try testing.expectEqualStrings("unknown", got.msg.rversion.version); - // ...and nothing else is served until a version is agreed. try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // An msize too small to hold one `Rwalk` cannot be served at all, and - // `Rversion` has no field that means "too small" — so `Rerror`, which is a - // legal reply to any T-message. try h.send(notag, .{ .tversion = .{ .msize = 64, .version = "9P2000" } }); got = try h.reap(); try testing.expectEqualStrings(e_small_msize, got.msg.rerror.ename); @@ -3624,8 +2330,6 @@ test "9p server: attach names the root, and the only tree there is" { try h.send(notag, .{ .tversion = .{ .msize = 4096, .version = "9P2000" } }); _ = try h.reap(); - // An `aname` names a tree we do not have, and saying so beats handing over - // the one we do. try h.send(1, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "work" } }); var got = try h.reap(); try testing.expectEqualStrings(e_no_tree, got.msg.rerror.ename); @@ -3634,22 +2338,16 @@ test "9p server: attach names the root, and the only tree there is" { got = try h.reap(); try testing.expectEqual(@as(u64, 1), got.msg.rattach.qid.path); try testing.expectEqual(qtdir, got.msg.rattach.qid.type); - // ALWAYS ZERO, which is what makes Linux's client skip its cache. try testing.expectEqual(@as(u32, 0), got.msg.rattach.qid.version); - // The same fid twice is a client bug, refused rather than rebound. try h.send(3, .{ .tattach = .{ .fid = 0, .afid = nofid, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename); - // `Tauth` is refused, which is how a Plan 9 mount learns there is no - // authentication here and carries on without it. try h.send(4, .{ .tauth = .{ .afid = 1, .uname = "goblin", .aname = "" } }); got = try h.reap(); try testing.expectEqualStrings(e_no_auth, got.msg.rerror.ename); - // The attacher's name is what `Rstat` reports as owner, group and last - // modifier: the tree is synthetic and has exactly one owner. try h.send(5, .{ .tstat = .{ .fid = 0 } }); got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); @@ -3662,8 +2360,6 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { var h: Harness = .{}; try h.handshake(4096); - // Three elements in one message, on a tree that is three deep: `..` at the - // root is the root, which is POSIX's rule and intro(5)'s, and NOT an error. var got = try h.walkTo(5, 1, &.{ "..", "1", "body" }); try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[0].path); @@ -3673,21 +2369,15 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { try testing.expectEqual(qtfile, got.msg.rwalk.wqid[2].type); for (got.msg.rwalk.wqid[0..3]) |q| try testing.expectEqual(@as(u32, 0), q.version); - // Up and down and up and down. `..` from a pane's directory is the root - // too, and five elements land where two would have. got = try h.walkTo(6, 2, &.{ "..", "1", "..", "1", "body" }); try testing.expectEqual(@as(u16, 5), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 1), got.msg.rwalk.wqid[2].path); try testing.expectEqual(@as(u64, 18), got.msg.rwalk.wqid[4].path); - // ...and the fid really is the body. try h.send(7, .{ .tstat = .{ .fid = 2 } }); got = try h.reap(); try testing.expectEqualStrings("body", got.msg.rstat.stat.name); try testing.expectEqual(@as(u64, 12), got.msg.rstat.stat.length); - // `..` after an element that landed on a FILE is that pane's directory — - // `parentOf`'s third case — and the name comes out of the node id rather - // than out of the element, because "1" is not what the client typed. got = try h.walkTo(8, 3, &.{ "1", "body", ".." }); try testing.expectEqual(@as(u16, 3), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[2].path); @@ -3696,8 +2386,6 @@ test "9p server: a three-element walk, and `..` with no kernel to resolve it" { got = try h.reap(); try testing.expectEqualStrings("1", got.msg.rstat.stat.name); - // `.` is where the fid already stands, and it costs the core NOTHING: no - // request is made for it at all. const before = h.fsys.calls; got = try h.walkTo(10, 4, &.{ ".", "." }); try testing.expectEqual(@as(u16, 2), got.msg.rwalk.nwqid); @@ -3709,23 +2397,17 @@ test "9p server: a walk failing on the first element is Rerror, on the second a var h: Harness = .{}; try h.handshake(4096); - // THE SCAR (`ad/.../sansio/server.rs:335-338`): zero qids already means - // the clone, so a failure on the first element cannot be spelled that way. var got = try h.walkTo(5, 1, &.{ "nope", "body" }); try testing.expectEqualStrings("No such file or directory", got.msg.rerror.ename); - // A failure LATER is a successful short `Rwalk` with no error string at - // all, which is the protocol's choice and not ours. got = try h.walkTo(6, 1, &.{ "1", "nope" }); try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid); try testing.expectEqual(@as(u64, 16), got.msg.rwalk.wqid[0].path); - // ...and `newfid` is NOT bound by a partial walk. try h.send(7, .{ .tstat = .{ .fid = 1 } }); got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); - // The clone does bind it, with zero qids and no lookups. try h.send(8, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 0 } }); got = try h.reap(); try testing.expectEqual(@as(u16, 0), got.msg.rwalk.nwqid); @@ -3733,24 +2415,84 @@ test "9p server: a walk failing on the first element is Rerror, on the second a got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); - // A walk with names from something that is not a directory is not a walk. got = try h.walkTo(10, 2, &.{"index"}); try testing.expectEqual(@as(u16, 1), got.msg.rwalk.nwqid); try h.send(11, .{ .twalk = .{ .fid = 2, .newfid = 3, .nwname = 1, .wname = wnames(&.{"body"}) } }); got = try h.reap(); try testing.expectEqualStrings(e_not_dir, got.msg.rerror.ename); - // A name no fid could hold is refused on its length rather than looked up, - // which is what keeps `Rstat`'s name field honest. got = try h.walkTo(12, 4, &.{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}); try testing.expectEqualStrings(e_illegal_name, got.msg.rerror.ename); - // An in-use `newfid` is refused before anything is walked. try h.send(13, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 1, .wname = wnames(&.{"1"}) } }); got = try h.reap(); try testing.expectEqualStrings(e_fid_in_use, got.msg.rerror.ename); } +test "9p server: backend-sized filenames survive walk and stat within negotiated msize" { + const NativeFs = struct { + pub const Req = StubFs.Req; + pub const Reply = StubFs.Reply; + pub const name_capacity: usize = 255; + }; + const Native = Server(NativeFs, 2); + try testing.expectEqual(@as(usize, 28), @sizeOf(@FieldType(Srv.Fid, "name"))); + try testing.expectEqual(@as(usize, 255), @sizeOf(@FieldType(Native.Fid, "name"))); + + const filename: [255]u8 = @splat('f'); + for ([_]struct { length: usize, msize: u32 }{ + .{ .length = 29, .msize = 512 }, + .{ .length = 128, .msize = 512 }, + .{ .length = 255, .msize = 512 }, + .{ .length = 200, .msize = 256 }, + }) |case| { + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var encoded: [1024]u8 = undefined; + var server = Native.init(.{ .in = &in, .out = &out, .root = 1 }); + server.msize = case.msize; + server.setUname("u" ** username_capacity); + server.fids[0] = .{ .used = true, .fid = 0, .node = 1, .dir = true, .perm = 0o500 }; + const name = filename[0..case.length]; + const walk = try encode(.{ .twalk = .{ + .fid = 0, + .newfid = 1, + .nwname = 1, + .wname = wnames(&.{name}), + } }, 1, &encoded); + try testing.expectEqual(walk.len, server.push(walk)); + const lookup = server.next() orelse return error.MissingLookup; + try testing.expectEqual(.lookup, lookup.op); + try testing.expectEqualStrings(name, lookup.data); + server.reply(&.{ .tag = lookup.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expectEqual(null, server.next()); + var response = try decode(server.output()); + try testing.expectEqual(@as(u16, 1), response.msg.rwalk.nwqid); + server.wrote(server.output().len); + + const stat = try encode(.{ .tstat = .{ .fid = 1 } }, 2, &encoded); + try testing.expectEqual(stat.len, server.push(stat)); + const getattr = server.next() orelse return error.MissingGetattr; + try testing.expectEqual(.getattr, getattr.op); + server.reply(&.{ .tag = getattr.tag, .attr = .{ .node = 2, .name = name, .size = 12 } }, ""); + try testing.expect(server.output().len <= case.msize); + response = try decode(server.output()); + if (case.msize == 256) { + try testing.expectEqualStrings(e_small_msize, response.msg.rerror.ename); + } else { + try testing.expectEqualStrings(name, response.msg.rstat.stat.name); + try testing.expectEqual(@as(u64, 12), response.msg.rstat.stat.length); + } + server.wrote(server.output().len); + const clunk = try encode(.{ .tclunk = .{ .fid = 1 } }, 3, &encoded); + try testing.expectEqual(clunk.len, server.push(clunk)); + try testing.expectEqual(null, server.next()); + response = try decode(server.output()); + try testing.expect(response.msg == .rclunk); + try testing.expect(!server.dead); + } +} + test "9p server: open then read then clunk, and the release a clunk owes the core" { var h: Harness = .{}; try h.handshake(4096); @@ -3759,10 +2501,8 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); var got = try h.reap(); try testing.expectEqual(@as(u64, 18), got.msg.ropen.qid.path); - // `iounit` is one message less the slack `fcall.h:72` reserves. try testing.expectEqual(@as(u32, 4096 - iohdrsz), got.msg.ropen.iounit); - // The fid IS the open, so a second one has nothing to mean. try h.send(7, .{ .topen = .{ .fid = 1, .mode = oread } }); got = try h.reap(); try testing.expectEqualStrings(e_already_open, got.msg.rerror.ename); @@ -3771,26 +2511,20 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor got = try h.reap(); try testing.expectEqualStrings("hello, body\n", got.msg.rread.data); - // Offsets are honoured on `body`, which is the whole reason `cat`, `wc` - // and `tail` work against this tree (docs/9p.typ §4). try h.send(9, .{ .tread = .{ .fid = 1, .offset = 7, .count = 4096 } }); got = try h.reap(); try testing.expectEqualStrings("body\n", got.msg.rread.data); - // Past the end is zero bytes, which is end of file and not an error. try h.send(10, .{ .tread = .{ .fid = 1, .offset = 99, .count = 16 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len); - // THE RELEASE. Without it a pane's `event` reader count never comes back - // down and the editor answers to a script that has gone. try testing.expectEqual(@as(u32, 0), h.fsys.releases); try h.send(11, .{ .tclunk = .{ .fid = 1 } }); got = try h.reap(); try testing.expect(got.msg == .rclunk); try testing.expectEqual(@as(u32, 1), h.fsys.releases); - // A FID USED AFTER CLUNK is a fid nobody knows. try h.send(12, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } }); got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); @@ -3798,7 +2532,6 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor got = try h.reap(); try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); - // A clunk of a fid that was never opened needs no release at all. _ = try h.walkTo(14, 2, &.{"index"}); try h.send(15, .{ .tclunk = .{ .fid = 2 } }); got = try h.reap(); @@ -3808,30 +2541,21 @@ test "9p server: open then read then clunk, and the release a clunk owes the cor test "9p server: every Rread is clamped to the client's count and to the msize" { var h: Harness = .{}; - // A small msize on purpose: `index` is a kilobyte and one message cannot - // carry it. try h.handshake(512); _ = try h.walkTo(5, 1, &.{"index"}); try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); _ = try h.reap(); - // The count, when the count is the smaller. try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 5 } }); var got = try h.reap(); try testing.expectEqual(@as(usize, 5), got.msg.rread.data.len); - // The MSIZE, when the client asks for more than one message can hold. An - // `Rread` longer than the count is a hard -EIO in Linux - // (`client.c:1475-1479`) and one longer than the msize is unreadable, so - // the answer is `msize - 11` exactly and the whole message is `msize`. try h.send(8, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } }); const out = h.srv.output(); try testing.expectEqual(@as(?u32, 512), frameLen(out)); got = try h.reap(); try testing.expectEqual(@as(usize, 512 - header_len - 4), got.msg.rread.data.len); - // And the core was never asked for bytes that would have been thrown - // away: the clamp is on the request too. try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 1 << 20 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 501), got.msg.rread.data.len); @@ -3840,17 +2564,12 @@ test "9p server: every Rread is clamped to the client's count and to the msize" test "9p server: a directory read is whole stat records at a cursor the client cannot invent" { var h: Harness = .{}; try h.handshake(4096); - // Walked before the root is opened, because an open fid cannot be walked - // (walk(5)) and the mode assertion at the bottom of this test needs it. _ = try h.walkTo(4, 1, &.{"index"}); try h.send(5, .{ .topen = .{ .fid = 0, .mode = oread } }); _ = try h.reap(); var found: [8][]const u8 = undefined; - // Two entries fit in 150 bytes; the third does not, so it is not counted - // and the next read asks for it by index. No cached entry anywhere, which - // is what `acmefs`'s re-staging buys (`acmefs.zig:1013-1016`). try h.send(6, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); var got = try h.reap(); const first = got.msg.rread.data.len; @@ -3859,8 +2578,6 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqualStrings("cons", found[1]); try testing.expect(first <= 150); - // THE RULE: the next read carries exactly the byte offset where the last - // one ended. Not the entry count, and not anything the client chose. try h.send(7, .{ .tread = .{ .fid = 0, .offset = first, .count = 150 } }); got = try h.reap(); const second = got.msg.rread.data.len; @@ -3868,8 +2585,6 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqualStrings("new", found[0]); try testing.expectEqualStrings("1", found[1]); - // An arbitrary offset is refused — even one that would land on an entry - // boundary, which is exactly the case `ad` accepts silently (`9P-5`). try h.send(8, .{ .tread = .{ .fid = 0, .offset = first + second + 1, .count = 150 } }); got = try h.reap(); try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename); @@ -3877,32 +2592,24 @@ test "9p server: a directory read is whole stat records at a cursor the client c got = try h.reap(); try testing.expectEqualStrings(e_bad_offset, got.msg.rerror.ename); - // The refusal did not move the cursor: the listing carries on. try h.send(10, .{ .tread = .{ .fid = 0, .offset = first + second, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); try testing.expectEqualStrings("2", found[0]); - // Zero bytes is END OF DIRECTORY, and it is not an error. try h.send(11, .{ .tread = .{ .fid = 0, .offset = first + second + got.msg.rread.data.len, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 0), got.msg.rread.data.len); - // Offset zero REWINDS, which is the only seek 9P allows in a directory. try h.send(12, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); got = try h.reap(); try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found)); try testing.expectEqualStrings("index", found[0]); - // A count too small for ONE entry is refused rather than answered with - // zero bytes, because zero bytes means end of directory and a client that - // believes it stops asking. try h.send(13, .{ .tread = .{ .fid = 0, .offset = 0, .count = 40 } }); got = try h.reap(); try testing.expectEqualStrings(e_count_small, got.msg.rerror.ename); - // A directory's entries carry the tree's default mode and a zero length; - // the exact bits come from `Tstat`, which asks the core. try h.send(14, .{ .tread = .{ .fid = 0, .offset = 0, .count = 150 } }); got = try h.reap(); const one = try Stat.decode(got.msg.rread.data[0 .. std.mem.readInt(u16, got.msg.rread.data[0..2], .little) + 2]); @@ -3917,6 +2624,39 @@ test "9p server: a directory read is whole stat records at a cursor the client c try testing.expectEqual(@as(u64, 1024), got.msg.rstat.stat.length); } +test "9p server: long directory names remain whole across pages" { + var h: Harness = .{}; + try h.handshake(4096); + var entries: [10 + 255 + 10 + 4]u8 = @splat(0); + std.mem.writeInt(u64, entries[0..8], 41, .little); + entries[9] = 255; + @memset(entries[10..265], 'f'); + std.mem.writeInt(u64, entries[265..273], 42, .little); + entries[274] = 4; + @memcpy(entries[275..], "next"); + var found: [2][]const u8 = undefined; + + h.srv.emitDirRead(5, 0, &entries, 330); + var got = try h.reap(); + const first = got.msg.rread.data.len; + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqual(@as(u64, 1), h.srv.fids[0].dirindex); + + h.srv.emitDirRead(6, 0, entries[265..], 330); + got = try h.reap(); + try testing.expectEqual(@as(usize, 1), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings("next", found[0]); + try testing.expectEqual(@as(u64, 2), h.srv.fids[0].dirindex); + try testing.expectEqual(first + got.msg.rread.data.len, h.srv.fids[0].diroff); + + h.srv.emitDirRead(7, 0, &entries, 4096); + got = try h.reap(); + try testing.expectEqual(@as(usize, 2), try dirNames(got.msg.rread.data, &found)); + try testing.expectEqualStrings(entries[10..265], found[0]); + try testing.expectEqualStrings("next", found[1]); +} + test "9p server: a blocked read parks, and the connection keeps working" { var h: Harness = .{}; try h.handshake(4096); @@ -3924,25 +2664,18 @@ test "9p server: a blocked read parks, and the connection keeps working" { try h.send(6, .{ .topen = .{ .fid = 1, .mode = oread } }); _ = try h.reap(); - // `Status.again`: nothing consumed, ask me later. NOT an error and NOT an - // empty read — the client hears nothing at all. try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // A retry round finds it, the core still has nothing, and it goes back. h.pump(); h.pump(); try h.quiet(); - // Meanwhile the connection is not blocked: another tag is served while the - // read waits, which is the entire point of parking rather than waiting. _ = try h.walkTo(8, 2, &.{ "1", "body" }); try h.send(9, .{ .tstat = .{ .fid = 2 } }); var got = try h.reap(); try testing.expectEqualStrings("body", got.msg.rstat.stat.name); - // The core has something now, and the retry round is what delivers it — - // with the tag the client used seven messages ago. h.fsys.event = "Kli7 7 0 0 hello\n"; h.pump(); got = try h.reap(); @@ -3950,8 +2683,6 @@ test "9p server: a blocked read parks, and the connection keeps working" { try testing.expectEqualStrings("Kli7 7 0 0 hello\n", got.msg.rread.data); try h.quiet(); - // A write the core parks is copied out of the input buffer, so the next - // message may overwrite it and the retry still has its bytes. _ = try h.walkTo(10, 3, &.{ "1", "ctl" }); try h.send(11, .{ .topen = .{ .fid = 3, .mode = owrite } }); _ = try h.reap(); @@ -3966,9 +2697,6 @@ test "9p server: a blocked read parks, and the connection keeps working" { try testing.expectEqual(@as(u16, 12), got.tag); try testing.expectEqual(@as(u32, 6), got.msg.rwrite.count); try testing.expectEqualStrings("clean\n", h.fsys.writes[0..h.fsys.writes_len]); - // Overflow is a refusal and not a queue. Thirty-two blocked readers is - // thirty-two scripts watching one session; the thirty-third is told to - // retry, which is honest, rather than dropped, which would hang it. for (0..max_slots) |k| { try h.send(@intCast(100 + k), .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); @@ -3982,8 +2710,6 @@ test "9p server: the reply queue is a FIFO that survives a partial write" { var h: Harness = .{}; try h.handshake(4096); - // A UART writes what it can. What is left stays, in order, and the next - // reply lands behind it rather than on top of it. try h.send(5, .{ .tstat = .{ .fid = 0 } }); var saved: [256]u8 = undefined; const one = h.srv.output(); @@ -3999,10 +2725,6 @@ test "9p server: the reply queue is a FIFO that survives a partial write" { const second = try decode(tail[0..frameLen(tail).?]); try testing.expectEqual(@as(u16, 6), second.tag); - // `push` takes what there is room for and says how much, which is the only - // back-pressure a server with no descriptor has. (A buffer of zeros is - // also a `size` no encoder produced, so the connection dies on it — which - // is the other half of what a caller has to handle.) var flood: [8192]u8 = @splat(0); try testing.expectEqual(@as(usize, 4096), h.srv.push(&flood)); h.pump(); @@ -4019,9 +2741,6 @@ test "9p server: Tflush answers the original first and the Rflush second" { try h.send(7, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // TWO messages, in this order and no other. A client that sees `Rflush` - // may reuse the tag, so a reply arriving after it would be a reply to - // whatever that tag names next. try h.send(8, .{ .tflush = .{ .oldtag = 7 } }); var got = try h.reap(); try testing.expectEqual(@as(u16, 7), got.tag); @@ -4031,14 +2750,10 @@ test "9p server: Tflush answers the original first and the Rflush second" { try testing.expect(got.msg == .rflush); try h.quiet(); - // The park slot is gone with it: the core producing an event now sends - // nothing, rather than a second answer to a tag the client has reused. h.fsys.event = "Kli7 7 0 0 hello\n"; h.pump(); try h.quiet(); - // A flush of a tag we do not hold is an `Rflush` and nothing else, which - // is the only promise flush(5) makes. try h.send(9, .{ .tflush = .{ .oldtag = 99 } }); got = try h.reap(); try testing.expectEqual(@as(u16, 9), got.tag); @@ -4050,7 +2765,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" var h: Harness = .{}; try h.handshake(4096); - // A fid nobody walked to. for ([_]Msg{ .{ .tread = .{ .fid = 99, .offset = 0, .count = 16 } }, .{ .tstat = .{ .fid = 99 } }, @@ -4063,8 +2777,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" try testing.expectEqualStrings(e_unknown_fid, got.msg.rerror.ename); } - // A fid that was never opened, and one opened the other way round. Both - // are `u9fs.c:755-758`'s two conditions. _ = try h.walkTo(5, 1, &.{ "1", "body" }); try h.send(6, .{ .tread = .{ .fid = 1, .offset = 0, .count = 16 } }); var got = try h.reap(); @@ -4075,13 +2787,10 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" got = try h.reap(); try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename); - // «must not have been opened for I/O» — walk(5). The fid IS the open. try h.send(9, .{ .twalk = .{ .fid = 1, .newfid = 2, .nwname = 0 } }); got = try h.reap(); try testing.expectEqualStrings(e_bad_use, got.msg.rerror.ename); - // THE PERMISSION CHECK IS OURS: there is no kernel above us to do it, and - // `errors` is write-only in acme's own dirtab. _ = try h.walkTo(10, 3, &.{ "1", "errors" }); try h.send(11, .{ .topen = .{ .fid = 3, .mode = oread } }); got = try h.reap(); @@ -4090,8 +2799,6 @@ test "9p server: the fid and permission refusals, each in a string Linux knows" got = try h.reap(); try testing.expect(got.msg == .ropen); - // A directory is read and only read; and nothing here can be removed, so - // nothing can be opened remove-on-close or executed either. try h.send(13, .{ .topen = .{ .fid = 0, .mode = ordwr } }); got = try h.reap(); try testing.expectEqualStrings(e_perm, got.msg.rerror.ename); @@ -4108,7 +2815,6 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try h.handshake(4096); _ = try h.walkTo(5, 1, &.{ "1", "body" }); - // The sentinels stat(5) specifies: an empty string, an all-ones integer. const sentinel: Stat = .{ .type = std.math.maxInt(u16), .dev = std.math.maxInt(u32), @@ -4123,22 +2829,17 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { .muid = "", }; - // Nothing to do: accepted and ignored, which is what a filesystem of live - // editor state has to do with a mode, an owner and two times. try h.send(6, .{ .twstat = .{ .fid = 1, .stat = sentinel } }); var got = try h.reap(); try testing.expect(got.msg == .rwstat); try testing.expectEqualStrings("hello, body\n", h.fsys.body); - // A length that is neither the sentinel nor zero. The core honours exactly - // one value, and this string is one Linux maps to EPERM rather than 526. var five = sentinel; five.length = 5; try h.send(7, .{ .twstat = .{ .fid = 1, .stat = five } }); got = try h.reap(); try testing.expectEqualStrings(e_trunc_only, got.msg.rerror.ename); - // A rename would change the shape of a tree that follows the pane list. var renamed = sentinel; renamed.name = "other"; try h.send(8, .{ .twstat = .{ .fid = 1, .stat = renamed } }); @@ -4146,7 +2847,33 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename); try testing.expectEqualStrings("hello, body\n", h.fsys.body); - // ...and zero IS the truncate. + var changes: [12]Stat = @splat(sentinel); + changes[0].type = 0; + changes[1].dev = 0; + changes[2].qid.type = 0; + changes[3].qid.version = 0; + changes[4].qid.path = 0; + changes[5].mode = 0o644; + changes[6].atime = 0; + changes[7].mtime = 0; + changes[8].name = "renamed"; + changes[9].uid = "owner"; + changes[10].gid = "group"; + changes[11].muid = "writer"; + for (changes) |change| { + for ([_]u64{ std.math.maxInt(u64), 0 }) |length| { + var attributes = change; + attributes.length = length; + const calls = h.fsys.calls; + try h.send(20, .{ .twstat = .{ .fid = 1, .stat = attributes } }); + got = try h.reap(); + try testing.expect(got.msg == .rerror); + try testing.expectEqualStrings(e_wstat, got.msg.rerror.ename); + try testing.expectEqual(calls, h.fsys.calls); + try testing.expectEqualStrings("hello, body\n", h.fsys.body); + } + } + var zero = sentinel; zero.length = 0; try h.send(9, .{ .twstat = .{ .fid = 1, .stat = zero } }); @@ -4154,9 +2881,6 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { try testing.expect(got.msg == .rwstat); try testing.expectEqualStrings("", h.fsys.body); - // The other spelling of the same thing, and the one a shell's `>` - // produces: `Topen` with `OTRUNC` is a truncate and then an open, in that - // order, and it is refused on a fid with no write permission. h.fsys.body = "hello, body\n"; _ = try h.walkTo(10, 2, &.{"index"}); try h.send(11, .{ .topen = .{ .fid = 2, .mode = oread | otrunc } }); @@ -4172,16 +2896,10 @@ test "9p server: create and remove are refused, and a remove clunks the fid anyw var h: Harness = .{}; try h.handshake(4096); - // Nothing in a generated tree is a client's to make. The one place a - // client DOES create something is `new/`, where the WALK creates a pane — - // so the capability exists and is not spelled `Tcreate` (`9P-18`). try h.send(5, .{ .tcreate = .{ .fid = 0, .name = "thing", .perm = 0o600, .mode = owrite } }); var got = try h.reap(); try testing.expectEqualStrings(e_perm, got.msg.rerror.ename); - // A remove is refused too — but «the fid is clunked even if the remove - // fails», which is the half of remove(5) that is easy to miss, and the - // release still goes to the core. _ = try h.walkTo(6, 1, &.{ "1", "body" }); try h.send(7, .{ .topen = .{ .fid = 1, .mode = ordwr } }); _ = try h.reap(); @@ -4198,8 +2916,6 @@ test "9p server: a message arriving a byte at a time is served when its last byt var h: Harness = .{}; try h.handshake(4096); - // The board's UART, and a socket that happened to split a write. Framing - // is `size[4]` and nothing may be served until all of it is in. var buf: [64]u8 = undefined; const bytes = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf); for (bytes[0 .. bytes.len - 1]) |b| { @@ -4212,8 +2928,6 @@ test "9p server: a message arriving a byte at a time is served when its last byt const got = try h.reap(); try testing.expectEqualStrings("/", got.msg.rstat.stat.name); - // TWO messages in one push are two replies, in order, and the input buffer - // ends up empty. var pair: [128]u8 = undefined; const a = try encode(.{ .tstat = .{ .fid = 0 } }, 6, &pair); const b = try encode(.{ .tstat = .{ .fid = 0 } }, 7, pair[a.len..]); @@ -4231,8 +2945,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" var buf: [64]u8 = undefined; const good = try encode(.{ .tstat = .{ .fid = 0 } }, 5, &buf); - // An R-message: a client on the wrong end of the connection, or the - // double-role link docs/9p.typ §7 says not to build. var raw: [64]u8 = undefined; @memcpy(raw[0..good.len], good); raw[4] = @intFromEnum(Type.rstat); @@ -4242,9 +2954,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" try testing.expectEqual(@as(u16, 5), got.tag); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // A type byte no dialect we serve defines — 8 is 9P2000.L's `Tstatfs` — - // still gets an answer, because the tag is at a fixed offset and a client - // that gets no reply hangs. @memcpy(raw[0..good.len], good); raw[4] = 8; _ = h.srv.push(raw[0..good.len]); @@ -4253,8 +2962,6 @@ test "9p server: what is not 9P2000 on this connection is refused, not guessed" try testing.expectEqual(@as(u16, 5), got.tag); try testing.expectEqualStrings(e_botch, got.msg.rerror.ename); - // A `size` no encoder could have produced is not a message to answer: the - // stream is not 9P and there is no resynchronising from it. @memcpy(raw[0..good.len], good); std.mem.writeInt(u32, raw[0..4], 3, .little); _ = h.srv.push(raw[0..good.len]); @@ -4272,21 +2979,14 @@ test "9p server: a connection that drops still pays the core its releases" { try h.send(@intCast(tag), .{ .topen = .{ .fid = fid, .mode = oread } }); _ = try h.reap(); } - // One blocked reader, so there is a parked request to abandon as well. try h.send(9, .{ .tread = .{ .fid = 1, .offset = 0, .count = 4096 } }); try h.quiet(); - // The socket died. Every open fid still owes the core a release, and that - // debt outlives the connection — losing it leaves the editor reporting - // button actions to a script that is gone. h.srv.hangup(); h.pump(); try testing.expectEqual(@as(u32, 2), h.fsys.releases); - // ...and nothing is written to a socket that has gone. try h.quiet(); - // `Tversion` is the same reset on a live connection: fids clunked, - // outstanding I/O abandoned, releases still paid (version(5)). var g: Harness = .{}; try g.handshake(4096); _ = try g.walkTo(5, 1, &.{ "1", "event" }); @@ -4298,7 +2998,6 @@ test "9p server: a connection that drops still pays the core its releases" { const v = try g.reap(); try testing.expectEqualStrings("9P2000", v.msg.rversion.version); try testing.expectEqual(@as(u32, 1), g.fsys.releases); - // The abandoned read is never answered, and the fid is gone. g.fsys.event = "Kli7 7 0 0 hello\n"; g.pump(); try g.quiet(); @@ -4308,10 +3007,6 @@ test "9p server: a connection that drops still pays the core its releases" { } test "9p server: every errno the core can answer is a string Linux knows" { - // The nine values `acmefs.E` defines, spelled exactly as - // `linux/net/9p/error.c:41-171` holds them. A typo in any of these is - // "Unknown error 526" on every `mount -t 9p`, which is why they are - // asserted as literals rather than derived from anything. try testing.expectEqualStrings("Operation not permitted", errString(1)); try testing.expectEqualStrings("No such file or directory", errString(2)); try testing.expectEqualStrings("Input/output error", errString(5)); @@ -4321,13 +3016,9 @@ test "9p server: every errno the core can answer is a string Linux knows" { try testing.expectEqualStrings("Too many open files in system", errString(23)); try testing.expectEqualStrings("No space left on device", errString(28)); try testing.expectEqualStrings("Function not implemented", errString(38)); - // A number this file never emits is EIO, not a table miss. try testing.expectEqualStrings("Input/output error", errString(0)); try testing.expectEqualStrings("Input/output error", errString(999)); - // The server's own strings, from the same table and the fossil/u9fs half - // of it. Every one of these is a line in `error.c`, which is the whole - // difference between an errno and 526. try testing.expectEqualStrings("fid unknown or out of range", e_unknown_fid); try testing.expectEqualStrings("fid already in use", e_fid_in_use); try testing.expectEqualStrings("bad use of fid", e_bad_use); @@ -4342,7 +3033,6 @@ test "9p server: every errno the core can answer is a string Linux knows" { try testing.expectEqualStrings("only support truncation to zero length", e_trunc_only); try testing.expectEqualStrings("wstat prohibited", e_wstat); try testing.expectEqualStrings("Resource temporarily unavailable", e_again); - // Every one of them fits the buffer a Plan 9 client has for it. for ([_][]const u8{ e_unknown_fid, e_fid_in_use, e_bad_use, e_bad_offset, e_perm, e_not_dir, e_already_open, e_botch, e_interrupted, e_trunc_only, @@ -4351,166 +3041,50 @@ test "9p server: every errno the core can answer is a string Linux knows" { }) |s| try testing.expect(s.len <= errmax); } -test "9p server: the fid table and the park table are what the board was costed for" { - // `docs/registry.typ` `9P-11` costed a fid table at 32 x 16 bytes. The - // real entry is larger, and the difference is not a mistake in either - // place: it is the entry NAME, which `Rstat` carries and a node id does - // not, plus the open handle and the two-coordinate directory cursor. The - // numbers are asserted here so that a change to `Fid` shows up as a diff - // in the board's budget rather than as a surprise on the board. - // - // TWO budgets, because there are now two numbers. `max_fids` is the desktop - // one and it is sized for a MOUNT, which keeps a fid per cached inode; - // `board_fids` is what a microcontroller serving its own small tree uses, - // and it is the one `9P-11` costed. - const S = Server(StubFs); - const entry = @sizeOf(S.Fid); - const slots = @sizeOf(S.Slot) * max_slots; - try testing.expect(slots <= 8 * 1024); - - // The board: two buffers at a 4,096-byte msize — `in` and `out`'s two — - // plus the two tables, against 336 KB of free heap on the P4. - const board = entry * board_fids; - try testing.expect(board <= 3 * 1024); - try testing.expect(3 * 4096 + board + slots <= 24 * 1024); - - // The desktop, against the ≈34 KiB per connection `fs9_service` budgets. - // Eight times the fids is ≈16 KiB, and it is the price of `find` working - // over a `9pfuse` mount — see `max_fids`. - try testing.expect(entry * max_fids <= 24 * 1024); +test "9p server: board and native capacities size the actual fid storage" { + const Board = Server(StubFs, board_fids); + const Native = Server(StubFs, max_fids); + const BoardFids = @FieldType(Board, "fids"); + const NativeFids = @FieldType(Native, "fids"); + try testing.expectEqual(32, @typeInfo(BoardFids).array.len); + try testing.expectEqual(256, @typeInfo(NativeFids).array.len); + try testing.expectEqual(32 * @sizeOf(Board.Fid), @sizeOf(BoardFids)); + try testing.expectEqual(256 * @sizeOf(Native.Fid), @sizeOf(NativeFids)); + try testing.expectEqual( + @sizeOf(NativeFids) - @sizeOf(BoardFids), + @sizeOf(Native) - @sizeOf(Board), + ); + try testing.expect(@sizeOf(BoardFids) <= 3 * 1024); + try testing.expect(@sizeOf(NativeFids) <= 24 * 1024); + try testing.expect(@sizeOf(@FieldType(Board, "slots")) <= 8 * 1024); + try testing.expect(3 * 4096 + @sizeOf(Board) <= 24 * 1024); } -// --------------------------------------------------------------------------- -// the client -// --------------------------------------------------------------------------- - -/// Tags one client may have outstanding at once. -/// -/// SIXTEEN, and the reasoning is `max_fids`': a fixed array with no allocator, -/// scanned rather than mapped, and a refusal rather than a queue when it fills. -/// The protocol's tag space is 0..0xFFFE — `notag` is 0xFFFF and belongs to the -/// handshake — so this uses the bottom sixteen of sixty-five thousand and never -/// a number above them. THE TAG IS ITS OWN INDEX, which is what makes matching -/// a reply O(1) with no search and no bookkeeping: see `tags`. -/// -/// MANY OUTSTANDING REQUESTS ARE LEGAL and the prior art imposes no bound at -/// all: Linux's client takes a tag per request out of an IDR -/// (`net/9p/client.c:194-199`) and Plan 9's devmnt keeps an `Mntrpc` per -/// request on a free list (`devmnt.c:783-800`), because on both the outstanding -/// count is «one per process blocked in an I/O», which the kernel already -/// bounds elsewhere. Here the count is "one per thing pardes is fetching", and -/// a screen does not hold sixteen remote panes. Overflow is `error.NoTags` at -/// the moment of asking — the caller collects an answer and asks again — and -/// never a silent wait, because a client that blocks is the one thing this -/// design does not have anywhere to put. pub const max_tags: usize = 16; -/// `Twrite`'s own header: `size[4] type[1] tag[2] fid[4] offset[8] count[4]`. -/// What `maxWrite` subtracts from the msize. -/// -/// NOT `iohdrsz`. That number (24) is the slack a SERVER quotes in `iounit` and -/// is deliberately larger than any real header; a client sizing its own request -/// against it leaves a byte on the table on every write forever. const twrite_header: usize = header_len + 4 + 8 + 4; -/// `Rread`'s header: `size[4] type[1] tag[2] count[4]`. What `maxRead` -/// subtracts, and the reason a client's `count` is not simply the msize — the -/// reply has to carry a header too, and a `count` of msize is a reply eleven -/// bytes too long for the connection that asked for it. const rread_header: usize = header_len + 4; comptime { assert(twrite_header == 23); assert(rread_header == 11); - // The tag IS the index, so the table's length is the tag space in use, and - // the handshake's `notag` must fall outside it or a `Rversion` would land - // on somebody's slot. assert(max_tags <= notag); - // At the smallest msize this file will agree to, a read and a write must - // both still be able to carry a byte, or a connection could be negotiated - // that cannot do any I/O at all. assert(msize_min > rread_header); assert(msize_min > twrite_header); } -/// Every way `submit` can refuse, and each is a different thing for the caller -/// to do about it. pub const ClientError = error{ - /// All `max_tags` are outstanding. Collect an answer and ask again. NoTags, - /// The out queue has no room for this request. Write `output()` out and - /// ask again. THE ONLY BACK-PRESSURE a sans-io client has. NoSpace, - /// The request cannot fit the negotiated msize: a `Twrite` past - /// `maxWrite()`, a `Tread` asking past `maxRead()`, a sixteen-element walk - /// of long names. REFUSED AND NOT CLAMPED, because `submit` answers with a - /// tag and nothing else — a silent clamp would leave the caller to guess - /// how much of its buffer went, and guess wrong about the offset to - /// continue from. `maxRead` and `maxWrite` are how a caller chunks first. TooLarge, - /// A request before `Rversion` has landed, or a second `Tversion` while - /// requests are outstanding. Handshake, - /// The stream is not 9P any more and this connection is finished. See - /// `dead`. Dead, - /// A request no encoding of 9P admits: `nofid` as a fid, an empty walk - /// element or one with a separator in it, more than `max_welem` elements. - /// A bug in the caller, caught here rather than spent as a round trip. BadRequest, }; -/// A 9P2000 client for one connection. -/// -/// THE MIRROR OF `Server`, and deliberately the same shape: caller-owned `in` -/// and `out` buffers, no allocator, no threads, no descriptor, `std` for -/// `readInt`/`writeInt` and nothing else. So it compiles for the board's -/// `riscv32-freestanding` and runs over `src/esp32p4/uart.zig`'s non-blocking -/// receive and bounded-spin transmit exactly as it runs over a unix socket in -/// `src/fs9_client.zig` — which is the whole reason for the shape, because a -/// client that owned its descriptor would be a client that could not. -/// -/// THE API IS A STATE MACHINE AND NOT `fn read() []u8`, because the core is -/// single-threaded and never blocks (docs/9p.typ §12.5). The three moving parts -/// are: -/// -/// 1. `submit(Request)` ENCODES a T-message into the out queue and hands -/// back its tag. It never waits and never touches a descriptor; a full -/// queue or a full tag table is a refusal the caller can act on. -/// 2. `push`/`output`/`wrote` move bytes, in whatever sizes the transport -/// manages, in whatever order they arrive. -/// 3. `take()` answers with the next COMPLETED operation, or null when there -/// is not a whole reply buffered yet. A caller's frame is -/// `while (client.take()) |done| ...`, which is `Server.next()`'s own -/// loop-until-null contract read from the other side. -/// -/// WHY COMPLETION IS A PULL AND NOT A CALLBACK: a callback would run inside -/// `push`, which is inside the transport's read, which is inside the host's -/// poll dispatch — and `src/fs9_service.zig` already states why filesystem -/// work must not happen there. Pulling puts the caller's own code back on the -/// caller's own stack. -/// -/// WHY THERE IS NO PER-TAG RESULT QUEUE: one frame completes exactly one -/// operation, and `take` returns it immediately, so there is never a completed -/// answer nobody has collected. That is what keeps a tag slot two bytes wide -/// instead of an msize wide, and it is why `Done` may borrow `in` (see there). -/// -/// REPLIES MAY ARRIVE IN ANY ORDER and this client does not care: the tag is -/// its own index into `tags`, so attribution is one bounds check and one -/// array read, with no assumption about arrival order anywhere in the file. -/// The reply's TYPE is checked against the request's `Op` as well, because a -/// tag is only as good as the table behind it. -/// -/// MEMORY: the two buffers, and `@sizeOf(Client)` for everything else — a -/// sixteen-entry tag table of eight-byte entries plus nine scalars, asserted at -/// the bottom of this file. Nothing here grows and nothing here is allocated. pub const Client = struct { - /// Reply bytes the caller has pushed. `in[0..frame]` is the reply most - /// recently returned by `take`, and every slice a `Done` holds points into - /// it — which is why nothing compacts this buffer until the next `take`. in: []u8, - /// Encoded requests, oldest first, as a byte FIFO. Every 9P message - /// carries its own length, so the queue needs no side table. out: []u8, in_len: usize = 0, @@ -4518,179 +3092,60 @@ pub const Client = struct { out_len: usize = 0, out_off: usize = 0, - /// Negotiated by the handshake; ZERO means "not on a protocol yet", and - /// nothing but `version` may be submitted in that state. It is also zero - /// after an `Rversion` of "unknown", which is a completed handshake with - /// no dialect in common. msize: u32 = 0, - /// What our own `Tversion` offered, kept only so that `Rversion` can be - /// checked against it: «the server responds with its own maximum, which - /// must be less than or equal to the client's». asked: u32 = 0, - /// A `Tversion` is outstanding. Its tag is `notag`, so it cannot live in - /// the table below — and it does not need to, because the protocol allows - /// nothing else to be outstanding beside it. versioning: bool = false, - /// The stream is not 9P and there is no resynchronising from it. Write-once, - /// like `Server.dead`: a reply that cannot be attributed is worse than a - /// closed connection, because the caller would wait on it forever. dead: bool = false, - /// THE TAG TABLE, indexed BY THE TAG. `tags[t].op` is null when tag `t` is - /// free, which makes claiming a tag a scan of sixteen and matching a reply - /// a single index — and it means a caller may keep its own per-request - /// state in a plain sixteen-entry array of its own, keyed the same way, - /// with no map on either side. tags: [max_tags]Slot = @splat(.{}), - /// What is remembered about one outstanding request, which is as little as - /// the protocol lets us get away with: what it was, and — for a read — - /// what it asked for, because `read(5)` bounds the reply by it and a - /// server that ignores that bound is handing back bytes at offsets we - /// never asked about. const Slot = struct { op: ?Op = null, count: u32 = 0, }; - /// What a client asked for. The tag names of `Request` and of `Result`'s - /// answers are these, so nothing maps one to the other by hand. - /// - /// EIGHT OPERATIONS AND NOT THIRTEEN, and the five absences are decisions: - /// - /// * `Tauth`: there is no authentication in this design and the server half - /// refuses it by name (`e_no_auth`). The socket's permissions are the - /// protection. - /// * `Tcreate`/`Tremove`: the server refuses both, because the shape of the - /// tree follows the pane list. Walking into `new/` is how a client creates - /// a pane, and that is a `walk`. - /// * `Twstat`: the one wstat the tree honours is a truncate, and a client - /// that wants to empty a file opens it `OTRUNC` in the same round trip. - /// * `Tflush`: nothing here has a cancel button. A flush costs a second tag - /// and brings a reply-ORDER rule with it — «the Rflush must come after the - /// original reply» — which is a rule nobody exercises if no caller can - /// change its mind, and an unexercised ordering rule in a protocol client - /// is a bug waiting for its first user. pub const Op = enum { version, attach, walk, open, read, write, clunk, stat }; - /// One request, as its caller states it. A `union(Op)` rather than eight - /// functions so that `submit` is one entry point with one refusal path: every - /// bound this client has — the tag table, the out queue, the msize — applies to - /// all eight identically, and a ninth operation cannot forget one of them. - /// - /// NO TAG FIELD: the tag is what `submit` HANDS BACK. A caller that chose its - /// own tags would be maintaining the table this file already maintains. pub const Request = union(Op) { - /// The handshake. `msize` is the largest message this client will send or - /// accept, and ZERO means "as much as my buffers hold", which is the - /// answer a caller with no opinion wants. Clamped to the buffers either - /// way; see `beginVersion`. version: struct { msize: u32 = 0 }, - /// `afid` is not a parameter: it is always `nofid`, because this client - /// never sends `Tauth`. attach: struct { fid: u32, uname: []const u8, aname: []const u8 = "" }, - /// The path elements, already split. A SLICE OF SLICES rather than the - /// codec's fixed `[max_welem]` array, because a caller has a path and not - /// an array: the copy into the fixed array happens once, in `submit`, - /// where the `nwname` bound is checked anyway. An empty list is the legal - /// zero-element walk, which clones `fid` onto `newfid`. walk: struct { fid: u32, newfid: u32, names: []const []const u8 }, open: struct { fid: u32, mode: u8 }, - /// `count` is refused rather than clamped above `maxRead()`; see there. read: struct { fid: u32, offset: u64, count: u32 }, - /// `data` is COPIED into the out queue by `submit` and is not borrowed - /// afterwards, which is what lets a caller write out of a buffer it is - /// about to reuse. write: struct { fid: u32, offset: u64, data: []const u8 }, clunk: struct { fid: u32 }, stat: struct { fid: u32 }, }; - /// What one request came to. The answer's SHAPE, which is what a caller acts - /// on; `Done.op` says which request it belongs to and `Done.tag` says which - /// one of several. pub const Result = union(enum) { - /// The server said no: `Rerror`'s string, and the only variant that can - /// answer ANY of the eight. Borrows the input buffer — see `Done`. fail: []const u8, - /// `version` is "9P2000", or the literal "unknown", which is a SUCCESSFUL - /// reply meaning no dialect in common. `Client.msize` is nonzero only in - /// the first case, so the second leaves a connection on which nothing can - /// be submitted and the caller hangs up. version: struct { msize: u32, version: []const u8 }, attach: Qid, - /// `nwqid` may be SHORTER than the walk's element count: a partial walk is - /// a success with fewer qids, and only a failure on the FIRST element is - /// an `Rerror`. So a caller MUST compare `nwqid` against what it asked for - /// before believing its fid landed anywhere. - /// - /// The whole array is carried rather than only the last qid, because the - /// last one is the only thing THIS tree's clients want and the - /// intermediate ones are what a caching client caches against - /// (`Qid.version`). Two hundred and eight bytes, on a value the caller - /// consumes and drops. walk: struct { nwqid: u16, wqid: [max_welem]Qid }, open: struct { qid: Qid, iounit: u32 }, - /// The bytes, borrowing the input buffer — see `Done`. SHORTER than the - /// requested count is normal and is not the end of the file; ZERO bytes is - /// the end of the file. read: []const u8, - /// The count actually written, which may be short — the caller advances - /// its offset by this and not by what it asked. write: u32, clunk: void, - /// Borrows the input buffer for its four strings — see `Done`. stat: Stat, }; - /// One completed operation. - /// - /// BORROWS THE INPUT BUFFER, and this is the whole lifetime rule: a `Done` is - /// valid until the next call to anything on the `Client` that produced it. The - /// `fail` string, the `read` bytes and the `stat` strings all point into - /// `Client.in`, exactly as `decode`'s do and for the same reason — the - /// alternative is a per-tag copy of every payload, which on the board is - /// sixteen msizes of static RAM to save a caller one `@memcpy` it may not even - /// want. `take` releases the previous answer's frame on entry, so the rule is - /// enforced by construction rather than by hope: a caller that keeps a `Done` - /// across a second `take` is reading bytes the next reply has been decoded - /// into. pub const Done = struct { - /// The tag `submit` handed out, or `notag` for the handshake. FREE again - /// the moment this is returned, so a caller that indexes its own - /// sixteen-entry table by tag must read this entry out before submitting - /// anything else. tag: u16, - /// Which of the eight this answers. Needed beside `result` because - /// `Rerror` answers all of them and carries no hint of which. op: Op, result: Result, }; pub const Options = struct { - /// Room for one whole reply. Caps the msize with `out`. in: []u8, - /// Room for one whole request, at least. MORE room is what buys - /// pipelining: sixteen outstanding `Tread`s are sixteen small messages - /// that all have to fit here at once, and `submit` answers - /// `error.NoSpace` rather than blocking when they do not. out: []u8, }; - /// The buffers are the caller's, which is what "no allocator" means from - /// this side: the board hands over two static arrays, a host hands over - /// two heap slices, and this file cannot tell the difference. The msize - /// follows from them and from the server's `Rversion`. pub fn init(opts: Options) Client { assert(opts.in.len >= msize_min); assert(opts.out.len >= msize_min); return .{ .in = opts.in, .out = opts.out }; } - /// The connection went away, or the caller is done with it. Unlike - /// `Server.hangup` there is no debt to pay: a client owes the far end - /// nothing on the way out — its fids are the server's to clean up when the - /// stream closes, which is exactly what `Server.hangup` is for. pub fn hangup(c: *Client) void { c.dead = true; c.tags = @splat(.{}); @@ -4703,20 +3158,6 @@ pub const Client = struct { c.out_off = 0; } - // -- bytes in, bytes out --------------------------------------------- - // - // The four `Server` has, written out again rather than shared. They look - // identical and they are not the same three lines: `Server.push` refuses - // once dead, and `Server.hasRoom` reserves a whole msize before a request - // is handed to the core so that no reply can fail to be written. A client - // reserves nothing — it refuses at `submit`, where the caller is standing - // right there — so a shared FIFO would be one struct with two callers and - // two exceptions, which is more to read than this is. - - /// Take as much of `bytes` as there is room for, and answer how much. A - /// short answer is not a loss: it is back-pressure, and the caller - /// re-offers the tail after `take`ing what it can. Bytes are APPENDED, so - /// the reply currently being borrowed by a `Done` does not move. pub fn push(c: *Client, bytes: []const u8) usize { if (c.dead) return 0; const n = @min(bytes.len, c.in.len - c.in_len); @@ -4725,14 +3166,10 @@ pub const Client = struct { return n; } - /// The requests waiting to go, oldest first, as one contiguous run of - /// whole 9P messages. Valid until the next call to anything else here. pub fn output(c: *const Client) []const u8 { return c.out[c.out_off..c.out_len]; } - /// How many of `output()`'s bytes actually left. A partial write is normal - /// on a UART and on a full socket, and the remainder stays put. pub fn wrote(c: *Client, n: usize) void { assert(n <= c.out_len - c.out_off); c.out_off += n; @@ -4742,9 +3179,6 @@ pub const Client = struct { } } - /// Slide the unwritten tail down. Called only when room is wanted, so the - /// common case — a fully written queue, reset to empty by `wrote` — never - /// moves a byte. fn compact(c: *Client) void { assert(c.out_off <= c.out_len); const n = c.out_len - c.out_off; @@ -4753,9 +3187,6 @@ pub const Client = struct { c.out_len = n; } - /// Release the reply `take` last returned and slide the rest of the input - /// down. One message-long move per message; a ring buffer would let a - /// decoded reply straddle the wrap and stop being one slice. fn dropFrame(c: *Client) void { assert(c.frame != 0); assert(c.frame <= c.in_len); @@ -4765,46 +3196,25 @@ pub const Client = struct { c.frame = 0; } - // -- what a caller may ask for --------------------------------------- - - /// The largest `Tread.count` this connection can answer, which is the - /// msize less `Rread`'s own header. Zero before the handshake. pub fn maxRead(c: *const Client) u32 { if (c.msize == 0) return 0; return c.msize - @as(u32, @intCast(rread_header)); } - /// The most bytes one `Twrite` can carry, which is the msize less - /// `Twrite`'s own header. Zero before the handshake. A caller with more - /// than this chunks; see `ClientError.TooLarge` for why it is not clamped. pub fn maxWrite(c: *const Client) u32 { if (c.msize == 0) return 0; return c.msize - @as(u32, @intCast(twrite_header)); } - /// Requests outstanding, the handshake included. What a caller's loop - /// tests to know whether there is anything left to wait for. pub fn pending(c: *const Client) usize { var n: usize = @intFromBool(c.versioning); for (c.tags) |t| n += @intFromBool(t.op != null); return n; } - // -- asking ------------------------------------------------------------ - - /// Encode one request into the out queue and hand back its tag. Never - /// blocks, never waits, never touches a descriptor. - /// - /// The refusals are in one order on purpose: what is wrong with the - /// REQUEST first, then what is wrong with this client's tables, so a - /// caller's bad argument never costs a tag and never half-fills the queue. pub fn submit(c: *Client, req: Request) ClientError!u16 { if (c.dead) return error.Dead; if (req == .version) return c.beginVersion(req.version.msize); - // «The client must communicate the version before any other messages» - // — and until `Rversion` has landed there is no msize to bound - // anything by, which is the same gate `Server.startFrame` applies from - // the other side. if (c.msize == 0 or c.versioning) return error.Handshake; const msg: Msg = switch (req) { @@ -4820,17 +3230,9 @@ pub const Client = struct { }, .walk => |m| blk: { if (m.fid == nofid or m.newfid == nofid) return error.BadRequest; - // `MAXWELEM` is a hard protocol bound and not a buffer size: - // every implementation refuses a seventeen-element walk, so a - // caller with a deeper path splits it into two walks. if (m.names.len > max_welem) return error.BadRequest; var w: [max_welem][]const u8 = @splat(""); for (m.names, 0..) |n, i| { - // An empty element, or one with a separator in it, is a - // caller that has not split its path. `Twalk` has no - // encoding for either and a server answers the first one - // `illegal name` — a round trip spent on a bug that was - // visible from here. if (n.len == 0) return error.BadRequest; if (std.mem.indexOfAny(u8, n, "/\x00") != null) return error.BadRequest; w[i] = n; @@ -4848,8 +3250,6 @@ pub const Client = struct { }, .read => |m| blk: { if (m.fid == nofid) return error.BadRequest; - // The one bound the request's own length does not express: - // what comes BACK has to fit the connection too. if (m.count > c.maxRead()) return error.TooLarge; break :blk .{ .tread = .{ .fid = m.fid, .offset = m.offset, .count = m.count } }; }, @@ -4866,10 +3266,6 @@ pub const Client = struct { break :blk .{ .tstat = .{ .fid = m.fid } }; }, }; - // ONE ceiling for every request, which is what makes a `Twrite` and a - // sixteen-element `Twalk` obey the same rule: the msize is «the - // maximum length, in bytes, ... including the size field», and a - // client that sends more is a client the server closes on. const need = totalLen(msg) catch return error.TooLarge; if (need > c.msize) return error.TooLarge; @@ -4881,24 +3277,10 @@ pub const Client = struct { return tag; } - /// `Tversion`, which is the one exchange with no tag and no msize behind - /// it. - /// - /// A SECOND ONE IS A CONNECTION RESET — «all fids are clunked and any - /// outstanding I/O is abandoned» (`version(5)`) — and abandoning somebody - /// else's request is not this function's decision to make. So it is - /// refused while anything is outstanding, and a caller that means to reset - /// collects its answers or hangs up first. fn beginVersion(c: *Client, want: u32) ClientError!u16 { if (c.pending() != 0) return error.Handshake; - // Two ceilings and the smaller wins: what one reply buffer holds, and - // what one request buffer holds. A caller with no opinion passes zero - // and gets both. const cap: u32 = @intCast(@min(c.in.len, c.out.len, std.math.maxInt(u32))); const m = @min(if (want == 0) cap else want, cap); - // Below the floor there is a connection that cannot carry an `Rwalk`, - // which is to say no connection at all. `init` asserts the buffers - // clear it, so this can only be a `want` the caller chose. if (m < msize_min) return error.BadRequest; try c.emit(notag, .{ .tversion = .{ .msize = m, .version = "9P2000" } }); c.msize = 0; @@ -4907,9 +3289,6 @@ pub const Client = struct { return notag; } - /// The lowest free tag, marked used. Lowest rather than round-robin so - /// that a client with one request outstanding always uses tag 0, which - /// makes a wire trace readable by eye. fn claim(c: *Client, op: Op) ?u16 { for (&c.tags, 0..) |*t, i| { if (t.op != null) continue; @@ -4919,67 +3298,37 @@ pub const Client = struct { return null; } - /// Queue one request. The only way this fails is room: `totalLen` has - /// already refused every other way `encode` can, which is why the error - /// set collapses to one value here. fn emit(c: *Client, tag: u16, msg: Msg) ClientError!void { if (c.out_off != 0) c.compact(); const bytes = encode(msg, tag, c.out[c.out_len..]) catch return error.NoSpace; c.out_len += bytes.len; } - // -- collecting -------------------------------------------------------- - - /// The next completed operation, or null when there is not a whole reply - /// buffered yet. Call in a loop until null, once per frame. - /// - /// A `Done` BORROWS the input buffer and is valid until the next call - /// here: the previous reply's frame is released on entry, which is what - /// makes that rule mechanical instead of a note somebody has to remember. pub fn take(c: *Client) ?Done { if (c.frame != 0) c.dropFrame(); if (c.dead) return null; const len = frameLen(c.in[0..c.in_len]) orelse return null; - // A `size` no encoder produced, or one this connection could never - // buffer: either way the stream is not 9P and waiting for the rest of - // it is waiting forever. if (len < header_len or len > c.in.len) return c.die(); - // And a server that sends past the msize it agreed to has stopped - // speaking the protocol it agreed to. if (c.msize != 0 and len > c.msize) return c.die(); if (len > c.in_len) return null; c.frame = len; - // A body this codec refuses is not a message we can attribute to a - // tag, so there is nobody to report it to. The connection ends. const got = decode(c.in[0..len]) catch return c.die(); return c.consume(got); } - /// The stream is finished. Returns null so that every refusal in `take` - /// and `consume` is one expression. fn die(c: *Client) ?Done { c.dead = true; return null; } - /// One decoded reply onto the request it answers. fn consume(c: *Client, got: Decoded) ?Done { - // A CLIENT READS R-MESSAGES. A T-message here is the other end of the - // connection talking, or the double-role link docs/9p.typ §7 tells us - // not to build — the exact mirror of `Server.startFrame`'s refusal, - // and the encoding's own parity does the work in both directions. if (isT(got.msg.msgType())) return c.die(); if (got.msg == .rversion) return c.version(got); - // Nothing may arrive before a `Tversion` has been answered, and - // nothing but the `Rversion` while one is outstanding. if (c.versioning or c.msize == 0) return c.die(); - // The tag is its own index, so this bounds check IS the lookup. if (got.tag >= max_tags) return c.die(); const slot = &c.tags[got.tag]; const op = slot.op orelse return c.die(); const result: Result = switch (got.msg) { - // `Rerror` answers ANY of the eight, which is exactly why `Done` - // reports the op beside it: the string does not say what failed. .rerror => |m| .{ .fail = m.ename }, .rattach => |m| if (op != .attach) return c.die() else .{ .attach = m.qid }, .rwalk => |m| if (op != .walk) return c.die() else .{ @@ -4990,78 +3339,35 @@ pub const Client = struct { }, .rread => |m| blk: { if (op != .read) return c.die(); - // «count ... indicates the number of bytes returned», and - // read(5) makes it no more than what was asked. Linux calls a - // longer one a hard `-EIO` (`net/9p/client.c:1475-1479`); here - // it ends the connection, because the byte after the ones we - // asked for is a byte we have no offset to put anywhere. if (m.data.len > slot.count) return c.die(); break :blk .{ .read = m.data }; }, .rwrite => |m| if (op != .write) return c.die() else .{ .write = m.count }, .rclunk => if (op != .clunk) return c.die() else .clunk, .rstat => |m| if (op != .stat) return c.die() else .{ .stat = m.stat }, - // The rest are replies to requests this client does not send — - // `Rauth`, `Rcreate`, `Rremove`, `Rwstat`, `Rflush` — and one is - // not an answer at all (`Rerror`'s illegal twin `Terror`, already - // refused by parity above). A reply to a request nobody made means - // the tag space is not what we think it is. else => return c.die(), }; slot.* = .{}; return .{ .tag = got.tag, .op = op, .result = result }; } - /// `Rversion`: the msize handshake, from the client's side. fn version(c: *Client, got: Decoded) ?Done { if (!c.versioning) return c.die(); - // «Rversion ... carries the same tag», and that tag is `notag`, - // because tags do not mean anything yet. if (got.tag != notag) return c.die(); const m = got.msg.rversion; - // «The server responds with its own maximum, which must be less than - // or equal to the client's» — `version(5)`. A larger one is a message - // we cannot buffer, and Linux refuses it for that reason - // (`net/9p/client.c:840-843`). if (m.msize > c.asked or m.msize < msize_min) return c.die(); c.versioning = false; if (std.mem.eql(u8, m.version, "9P2000")) { c.msize = m.msize; } else if (!std.mem.eql(u8, m.version, "unknown")) { - // The reply must be a version the client offered, or "unknown". - // Anything else — "9P2000.u", "9P2000.L", a typo — is a server - // answering a question we did not ask, and agreeing to a dialect - // this file does not implement is how a client sends a `Tattach` - // whose layout the other end reads differently. return c.die(); } - // "unknown" leaves `msize` at zero: a completed handshake with no - // dialect in common, on which nothing can be submitted. The CALLER - // decides whether that is worth hanging up over, which is the honest - // place for it — a fallback ladder of dialects is a policy and this is - // a codec. return .{ .tag = notag, .op = .version, .result = .{ .version = .{ .msize = m.msize, .version = m.version }, } }; } }; -// --------------------------------------------------------------------------- -// client tests -// --------------------------------------------------------------------------- -// -// Driven against the SERVER IN THIS FILE, in process, over two pairs of -// buffers. That is the strongest test available here and it needs no socket: -// every byte the client encodes is a byte the server decodes and vice versa, -// so a disagreement about a layout, a length or a tag fails a test rather than -// waiting for a live daemon. The stub filesystem is the server tests' own, so -// the tree the client walks is the tree those tests already pin down. - -/// One connection with a client at each end of it. Four buffers, because each -/// side owns its own two and neither may see the other's. -/// -/// `srv.out` is twice the msize because `Server` requires it; `cli.out` is not, -/// because a client reserves nothing — see `Client.Options`. const Pair = struct { srv_in: [4096]u8 = undefined, srv_out: [8192]u8 = undefined, @@ -5071,8 +3377,6 @@ const Pair = struct { srv: Srv = undefined, cli: Client = undefined, - /// The buffers are fields, so neither end can be built until the pair has - /// an address. fn start(p: *Pair) void { p.srv = Srv.init(.{ .in = &p.srv_in, .out = &p.srv_out, .root = 1 }); p.cli = Client.init(.{ .in = &p.cli_in, .out = &p.cli_out }); @@ -5083,9 +3387,6 @@ const Pair = struct { p.srv.reply(&a.reply, a.bytes); } - /// THE WIRE: every byte both ways, and each side given every chance to - /// work, until nothing moves. A real transport does this a chunk at a time - /// in a poll loop; the tests that care about that drip bytes by hand. fn wire(p: *Pair) void { var moved = true; while (moved) { @@ -5113,23 +3414,16 @@ const Pair = struct { } } - /// Submit one request, run the wire, and collect the one answer it - /// produced. The tag and the op are checked here so that no test below has - /// to repeat it. fn one(p: *Pair, req: Client.Request) !Client.Done { const tag = try p.cli.submit(req); p.wire(); const done = p.cli.take() orelse return error.NoReply; try testing.expectEqual(tag, done.tag); try testing.expectEqual(std.meta.activeTag(req), done.op); - // One request, one reply, and nothing left outstanding: the invariant - // that makes `pending()` usable as a loop condition. try testing.expectEqual(@as(usize, 0), p.cli.pending()); return done; } - /// `Tversion` and `Tattach`, leaving the root on fid 0 — the client-side - /// twin of `Harness.handshake`. fn handshake(p: *Pair) !void { p.start(); const v = try p.one(.{ .version = .{} }); @@ -5144,14 +3438,10 @@ const Pair = struct { test "9p client: a whole session against the server in this file" { var p: Pair = .{}; try p.handshake(); - // Both ends agreed the same number, and it came off the buffers rather - // than out of the air. try testing.expectEqual(@as(u32, 4096), p.cli.msize); try testing.expectEqual(@as(u32, 4096 - 11), p.cli.maxRead()); try testing.expectEqual(@as(u32, 4096 - 23), p.cli.maxWrite()); - // A two-element walk onto pane 1's `body`. `nwqid` equals what was asked, - // which is the only thing that says the fid landed where we wanted. const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); try testing.expectEqual(@as(u16, 2), w.result.walk.nwqid); try testing.expectEqual(@as(u64, 16), w.result.walk.wqid[0].path); @@ -5164,8 +3454,6 @@ test "9p client: a whole session against the server in this file" { const r = try p.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 64 } }); try testing.expectEqualStrings("hello, body\n", r.result.read); - // Past the end is zero bytes and not an error: 9P has no EOF flag, and a - // short read is how a client learns it is done. const eof = try p.one(.{ .read = .{ .fid = 1, .offset = 12, .count = 64 } }); try testing.expectEqual(@as(usize, 0), eof.result.read.len); @@ -5179,22 +3467,13 @@ test "9p client: a whole session against the server in this file" { try testing.expectEqualStrings("goblin", st.result.stat.uid); _ = try p.one(.{ .clunk = .{ .fid = 1 } }); - // The clunk paid the core its release, which is the half of a clunk a - // client cannot see and the server tests pin down from the other side. try testing.expectEqual(@as(u32, 1), p.fsys.releases); - // Nothing outstanding, nothing buffered, nothing owed. try testing.expectEqual(@as(usize, 0), p.cli.pending()); try testing.expectEqual(@as(usize, 0), p.cli.output().len); try testing.expect(p.cli.take() == null); try testing.expect(!p.cli.dead); } -/// Move the server's queued replies to the client LAST FIRST. 9P permits it — -/// nothing in the protocol orders replies against each other — and both -/// reference clients allocate a tag per outstanding request with no in-order -/// assumption anywhere (`linux/net/9p/client.c:194-199`, -/// `plan9/devmnt.c:783-800`). A client that quietly relies on order works -/// until the day the server answers a cached stat before a blocked read. fn deliverReversed(p: *Pair) !void { var scratch: [4096]u8 = undefined; const out = p.srv.output(); @@ -5229,15 +3508,12 @@ test "9p client: replies out of order are matched by tag and not by arrival" { const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"index"} } }); try testing.expectEqual(@as(u16, 1), w.result.walk.nwqid); - // Two stats outstanding at once, on two different files. const root_tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); const index_tag = try p.cli.submit(.{ .stat = .{ .fid = 1 } }); try testing.expectEqual(@as(u16, 0), root_tag); try testing.expectEqual(@as(u16, 1), index_tag); try testing.expectEqual(@as(usize, 2), p.cli.pending()); - // Both requests to the server, both replies produced, then handed back in - // the wrong order. while (p.cli.output().len != 0) { const n = p.srv.push(p.cli.output()); p.cli.wrote(n); @@ -5245,7 +3521,6 @@ test "9p client: replies out of order are matched by tag and not by arrival" { while (p.srv.next()) |req| p.answer(req); try deliverReversed(&p); - // The SECOND request answers first, and it is recognised by its tag. const first = p.cli.take() orelse return error.NoReply; try testing.expectEqual(index_tag, first.tag); try testing.expectEqualStrings("index", first.result.stat.name); @@ -5260,22 +3535,15 @@ test "9p client: an Rerror answers one operation and the session carries on" { var p: Pair = .{}; try p.handshake(); - // A walk failing on its FIRST element is an `Rerror` rather than a short - // `Rwalk` — the one asymmetry in walk(5), and the reason `Done` reports - // the op beside the string. const bad = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"nope"} } }); try testing.expectEqual(Client.Op.walk, bad.op); try testing.expectEqualStrings(errString(2), bad.result.fail); - // The failed tag is free again and the connection is untouched: an error - // is an answer, not a fault. try testing.expectEqual(@as(usize, 0), p.cli.pending()); try testing.expect(!p.cli.dead); const st = try p.one(.{ .stat = .{ .fid = 0 } }); try testing.expectEqualStrings("/", st.result.stat.name); - // A refusal that comes from the server's own table rather than the core's, - // spelled the way Linux's error table holds it. const stale = try p.one(.{ .stat = .{ .fid = 9 } }); try testing.expectEqualStrings(e_unknown_fid, stale.result.fail); try testing.expect(!p.cli.dead); @@ -5286,8 +3554,6 @@ test "9p client: a reply arriving a byte at a time is taken when its last byte l try p.handshake(); const tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); - // The request out, the reply produced, and then held on this side of the - // wire so it can be dripped in. while (p.cli.output().len != 0) { const n = p.srv.push(p.cli.output()); p.cli.wrote(n); @@ -5300,8 +3566,6 @@ test "9p client: a reply arriving a byte at a time is taken when its last byte l const reply = scratch[0..out.len]; p.srv.wrote(reply.len); - // Every byte but the last leaves nothing to collect — including the first - // four, where `frameLen` becomes readable and still says "wait". for (reply[0 .. reply.len - 1]) |b| { try testing.expectEqual(@as(usize, 1), p.cli.push(&.{b})); try testing.expect(p.cli.take() == null); @@ -5317,21 +3581,17 @@ test "9p client: sixteen tags outstanding, and the seventeenth is refused" { var p: Pair = .{}; try p.handshake(); - // Nothing is wired, so nothing is answered and every tag stays out. var tags: [max_tags]u16 = undefined; for (&tags, 0..) |*t, i| { t.* = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); - // Lowest free tag first, which is what makes a trace readable. try testing.expectEqual(@as(u16, @intCast(i)), t.*); } try testing.expectEqual(max_tags, p.cli.pending()); try testing.expectError(error.NoTags, p.cli.submit(.{ .stat = .{ .fid = 0 } })); - // A refused submit costs nothing: no tag, and not a byte in the queue. const owed = p.cli.output().len; try testing.expectError(error.NoTags, p.cli.submit(.{ .clunk = .{ .fid = 0 } })); try testing.expectEqual(owed, p.cli.output().len); - // Drained, every tag comes back, and the seventeenth request now fits. p.wire(); var seen: [max_tags]bool = @splat(false); for (0..max_tags) |_| { @@ -5349,22 +3609,18 @@ test "9p client: what a caller may not ask for is refused before a tag is spent" var p: Pair = .{}; p.start(); - // Nothing before the handshake, and `Tversion` is the only exception. try testing.expectError(error.Handshake, p.cli.submit(.{ .stat = .{ .fid = 0 } })); try p.handshake(); - // `NOFID` is not a fid a client may name. try testing.expectError(error.BadRequest, p.cli.submit(.{ .stat = .{ .fid = nofid } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .clunk = .{ .fid = nofid } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = nofid, .names = &.{} } })); - // A path that has not been split, and one longer than the protocol admits. try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"1/body"} } })); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{""} } })); const seventeen: [max_welem + 1][]const u8 = @splat("x"); try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &seventeen } })); - // Both I/O bounds, each one byte past what the msize can carry. try testing.expectError(error.TooLarge, p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, @@ -5376,23 +3632,12 @@ test "9p client: what a caller may not ask for is refused before a tag is spent" .offset = 0, .data = big[0 .. p.cli.maxWrite() + 1], } })); - // And exactly at the bound, both fit — a cap that is off by one is a cap - // that costs a round trip on every large transfer. Each one on an EMPTY - // queue, which is what `Options.out` means by "room for one whole - // request": a maximum-size `Twrite` IS the msize, so it fits beside - // nothing at all. p.cli.wrote(p.cli.output().len); _ = try p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, .count = p.cli.maxRead() } }); p.cli.wrote(p.cli.output().len); _ = try p.cli.submit(.{ .write = .{ .fid = 0, .offset = 0, .data = big[0..p.cli.maxWrite()] } }); - // A second `Tversion` resets the connection, so it is refused while - // anything is outstanding rather than abandoning it. try testing.expectError(error.Handshake, p.cli.submit(.{ .version = .{} })); - // And with the queue full of that one write there is nowhere to put even - // an eleven-byte `Tstat`: the out queue is the only back-pressure a - // sans-io client has, and it lands at `submit` where the caller is - // standing right there. try testing.expectError(error.NoSpace, p.cli.submit(.{ .stat = .{ .fid = 0 } })); } @@ -5401,22 +3646,15 @@ test "9p client: an msize below the floor, and one the server tried to raise" { var out: [512]u8 = undefined; var buf: [64]u8 = undefined; - // A caller asking for less than an `Rwalk` is asking for a connection that - // cannot be served. var c = Client.init(.{ .in = &in, .out = &out }); try testing.expectError(error.BadRequest, c.submit(.{ .version = .{ .msize = msize_min - 1 } })); - // Zero means "whatever the buffers hold", which is the smaller of the two. _ = try c.submit(.{ .version = .{} }); try testing.expectEqual(@as(u32, 512), c.asked); - // A server answering with MORE than the client offered is a server whose - // next message will not fit the buffer that has to hold it. _ = c.push(try encode(.{ .rversion = .{ .msize = 1024, .version = "9P2000" } }, notag, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); - // "unknown" is a SUCCESSFUL reply with no dialect in common: the handshake - // completes, `msize` stays zero, and nothing more can be submitted. var c2 = Client.init(.{ .in = &in, .out = &out }); _ = try c2.submit(.{ .version = .{} }); _ = c2.push(try encode(.{ .rversion = .{ .msize = 512, .version = "unknown" } }, notag, &buf)); @@ -5426,8 +3664,6 @@ test "9p client: an msize below the floor, and one the server tried to raise" { try testing.expectEqual(@as(u32, 0), c2.msize); try testing.expectError(error.Handshake, c2.submit(.{ .stat = .{ .fid = 0 } })); - // A dialect we never offered is neither: agreeing to it would be agreeing - // to a layout this file does not implement. var c3 = Client.init(.{ .in = &in, .out = &out }); _ = try c3.submit(.{ .version = .{} }); _ = c3.push(try encode(.{ .rversion = .{ .msize = 512, .version = "9P2000.u" } }, notag, &buf)); @@ -5437,8 +3673,6 @@ test "9p client: an msize below the floor, and one the server tried to raise" { test "9p client: what is not an answer to one of our requests ends the connection" { var buf: [64]u8 = undefined; - // Each case gets a fresh connection past the handshake, because every one - // of them is fatal by design. const Case = struct { fn armed(in: []u8, out: []u8, scratch: []u8) !Client { var c = Client.init(.{ .in = in, .out = out }); @@ -5454,45 +3688,36 @@ test "9p client: what is not an answer to one of our requests ends the connectio var in: [512]u8 = undefined; var out: [512]u8 = undefined; - // A T-message. A client reads R-messages, and the parity says so with no - // table: this is `Server.startFrame`'s refusal read from the other end. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.{ .tstat = .{ .fid = 0 } }, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A reply on a tag nobody claimed. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 3, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A tag outside the table entirely, which no reply to us can carry. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 900, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // The right tag and the WRONG SHAPE: an `Rclunk` where an `Rstat` was - // asked for. A tag is only as good as the table behind it. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rclunk, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A reply to a request this client never sends. { var c = try Case.armed(&in, &out, &buf); _ = c.push(try encode(.rwstat, 0, &buf)); try testing.expect(c.take() == null); try testing.expect(c.dead); } - // A `size` no encoder produced, and one past the negotiated msize. Both - // are streams that will never resynchronise. { var c = try Case.armed(&in, &out, &buf); _ = c.push(&.{ 3, 0, 0, 0 }); @@ -5505,7 +3730,6 @@ test "9p client: what is not an answer to one of our requests ends the connectio try testing.expect(c.take() == null); try testing.expect(c.dead); } - // And a body the codec refuses: the type byte is fine, the payload is not. { var c = try Case.armed(&in, &out, &buf); _ = c.push(&.{ 8, 0, 0, 0, @intFromEnum(Type.rstat), 0, 0, 0 }); @@ -5515,9 +3739,6 @@ test "9p client: what is not an answer to one of our requests ends the connectio } test "9p client: an Rread longer than the Tread asked for is refused" { - // The one bound a client cannot check from the frame alone, which is why - // `Slot` keeps the count: a server handing back more than was asked has - // given us bytes at offsets we never named. Linux calls it `-EIO`. var p: Pair = .{}; try p.handshake(); _ = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); @@ -5530,7 +3751,6 @@ test "9p client: an Rread longer than the Tread asked for is refused" { try testing.expect(p.cli.take() == null); try testing.expect(p.cli.dead); - // Exactly the count asked for is fine, and so is anything shorter. var q: Pair = .{}; try q.handshake(); _ = try q.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); @@ -5552,18 +3772,8 @@ test "9p client: hangup and a dead connection refuse everything after" { } test "9p client: one session is a hundred and change bytes plus its buffers" { - // The number the board is costed against, and the whole reason the client - // is shaped the way it is: sixteen eight-byte tag slots and nine scalars, - // with every payload borrowed out of the input buffer rather than copied - // into a per-tag one. A `Server` on the same connection is 9,488 B because - // it owns a fid table and a park table; a client owns neither, because the - // far end does. try testing.expect(@sizeOf(Client.Slot) <= 8); try testing.expect(@sizeOf(Client) <= 256); - // Two buffers at the 8,192-byte msize `src/fs9_service.zig` serves, plus - // the client itself: what one `9p` word costs while it is running. try testing.expect(2 * 8192 + @sizeOf(Client) <= 17 * 1024); - // And at the protocol floor, which is what a board would negotiate: two - // buffers of 217 bytes each is a 9P client in under 700 bytes of RAM. try testing.expect(2 * msize_min + @sizeOf(Client) <= 700); } diff --git a/src/9p_io.zig b/src/9p_io.zig new file mode 100644 index 00000000..89611c2b --- /dev/null +++ b/src/9p_io.zig @@ -0,0 +1,1454 @@ +const std = @import("std"); +const libc = std.c; +const builtin = @import("builtin"); +const ninep = @import("9p.zig"); +const pardes = @import("pardes.zig"); +const limits = @import("memory.zig").limits; +pub const quic_enabled = @import("9p_options").quic; +const quic = if (quic_enabled) @import("9p_quic.zig") else struct {}; + +const log = std.log.scoped(.ninep); + +pub const darwin = switch (builtin.os.tag) { + .macos, .ios, .tvos, .watchos, .visionos => true, + else => false, +}; +pub const supported = builtin.os.tag == .linux or darwin; +pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; + +pub fn setCloexec(fd: c_int) void { + _ = libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)); +} + +pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { + if (libc.getenv("XDG_RUNTIME_DIR")) |path| + return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(path)}, 0) catch null; + const home = libc.getenv("HOME") orelse return null; + return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null; +} + +pub const FileFacts = struct { mode: u32, uid: libc.uid_t }; + +pub fn statNoFollow(path: [:0]const u8) ?FileFacts { + if (comptime darwin) { + var stat: libc.Stat = undefined; + if (libc.fstatat(libc.AT.FDCWD, path, &stat, libc.AT.SYMLINK_NOFOLLOW) != 0) return null; + return .{ .mode = stat.mode, .uid = stat.uid }; + } else { + const linux = std.os.linux; + var stat: linux.Statx = undefined; + const fields: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; + if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, fields, &stat) != 0) return null; + return .{ .mode = stat.mode, .uid = stat.uid }; + } +} + +pub fn ensureSocketDir(dir: [:0]const u8) bool { + if (dir.len == 0) return false; + var partial: [sun_path_len:0]u8 = undefined; + @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); + for (1..dir.len) |i| { + if (dir[i] != '/') continue; + partial[i] = 0; + _ = libc.mkdir(partial[0..i :0], 0o700); + partial[i] = '/'; + } + _ = libc.mkdir(dir, 0o700); + const stat = statNoFollow(dir) orelse return false; + return stat.mode & 0o170000 == 0o040000 and stat.uid == libc.getuid() and stat.mode & 0o077 == 0; +} + +const prefix = "pardes-9p-"; + +pub const msize: u32 = 8192; + +pub const max_conns = 4; + +extern "c" fn inet_pton(family: c_int, src: [*:0]const u8, dst: *anyopaque) c_int; + +pub fn networkAddress(dial: []const u8, allow_zero_port: bool) error{BadDial}!std.Io.net.IpAddress { + if (comptime !supported) return error.BadDial; + const host_start: usize = if (std.mem.startsWith(u8, dial, "tcp!")) 4 else if (std.mem.startsWith(u8, dial, "quic!")) 5 else return error.BadDial; + const split = std.mem.lastIndexOfScalar(u8, dial, '!') orelse return error.BadDial; + if (split <= host_start or split + 1 == dial.len) return error.BadDial; + const port_text = dial[split + 1 ..]; + for (port_text) |c| if (c < '0' or c > '9') return error.BadDial; + const port = std.fmt.parseInt(u16, port_text, 10) catch return error.BadDial; + if (port == 0 and !allow_zero_port) return error.BadDial; + const host = dial[host_start..split]; + if (std.mem.indexOfScalar(u8, host, 0) != null) return error.BadDial; + var host_buf: [46]u8 = undefined; + const host_z = std.fmt.bufPrintSentinel(&host_buf, "{s}", .{host}, 0) catch return error.BadDial; + var ip4: std.Io.net.Ip4Address = .{ .port = port, .bytes = undefined }; + if (inet_pton(libc.AF.INET, host_z, &ip4.bytes) == 1) return .{ .ip4 = ip4 }; + var ip6: std.Io.net.Ip6Address = .{ .port = port, .bytes = undefined }; + if (inet_pton(libc.AF.INET6, host_z, &ip6.bytes) == 1) return canonicalIp(.{ .ip6 = ip6 }); + return error.BadDial; +} + +fn canonicalIp(address: std.Io.net.IpAddress) std.Io.net.IpAddress { + if (address == .ip6) { + if (std.Io.net.Ip4Address.fromIp6(address.ip6)) |ip4| return .{ .ip4 = ip4 }; + } + return address; +} + +fn ipSockaddr(address: std.Io.net.IpAddress, out: *libc.sockaddr.storage) libc.socklen_t { + return switch (address) { + .ip4 => |ip| blk: { + const addr: *libc.sockaddr.in = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = @bitCast(ip.bytes) }; + break :blk @sizeOf(libc.sockaddr.in); + }, + .ip6 => |ip| blk: { + const addr: *libc.sockaddr.in6 = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = ip.bytes, .flowinfo = 0, .scope_id = 0 }; + break :blk @sizeOf(libc.sockaddr.in6); + }, + }; +} + +fn sockaddrIp(address: *const libc.sockaddr) ?std.Io.net.IpAddress { + return switch (address.family) { + libc.AF.INET => blk: { + const addr: *const libc.sockaddr.in = @ptrCast(@alignCast(address)); + break :blk .{ .ip4 = .{ .port = std.mem.bigToNative(u16, addr.port), .bytes = @bitCast(addr.addr) } }; + }, + libc.AF.INET6 => blk: { + const addr: *const libc.sockaddr.in6 = @ptrCast(@alignCast(address)); + break :blk canonicalIp(.{ .ip6 = .{ .port = std.mem.bigToNative(u16, addr.port), .bytes = addr.addr } }); + }, + else => null, + }; +} + +const IfAddr = extern struct { + next: ?*IfAddr, + name: ?[*:0]u8, + flags: c_uint, + address: ?*libc.sockaddr, + netmask: ?*libc.sockaddr, + destination: ?*libc.sockaddr, + data: ?*anyopaque, +}; + +extern "c" fn getifaddrs(out: *?*IfAddr) c_int; +extern "c" fn freeifaddrs(first: *IfAddr) void; + +fn localIp(address: std.Io.net.IpAddress) bool { + switch (address) { + .ip4 => |ip| if (ip.bytes[0] == 127 or std.mem.allEqual(u8, &ip.bytes, 0)) return true, + .ip6 => |ip| if (ip.isLoopBack() or std.mem.allEqual(u8, &ip.bytes, 0)) return true, + } + var first: ?*IfAddr = null; + if (getifaddrs(&first) != 0) return false; + defer if (first) |head| freeifaddrs(head); + var next = first; + while (next) |entry| : (next = entry.next) { + var local = sockaddrIp(entry.address orelse continue) orelse continue; + local.setPort(address.getPort()); + if (address.eql(&local)) return true; + } + return false; +} + +const Srv = ninep.Server(pardes.filesystem, ninep.max_fids); + +const Conn = struct { + fd: c_int = -1, + quic: if (quic_enabled) ?quic.Connection else void = if (quic_enabled) null else {}, + draining: bool = false, + accepted_ms: i64 = 0, + srv: Srv = undefined, + in: [msize]u8 = undefined, + out: [2 * msize]u8 = undefined, + + fn step(c: *Conn, core: *pardes.Pardes, req: pardes.filesystem.Req) void { + core.update(.{ .fs_req = req }); + var answered = false; + while (core.nextEffect()) |effect| { + if (effect == .fs_reply) { + const reply = effect.fs_reply; + if (reply.tag == req.tag) answered = true; + c.srv.reply(&reply, core.fsPayload(reply)); + } else core.perform(effect); + } + if (!answered) { + const reply = pardes.filesystem.Reply.fail(req.tag, pardes.filesystem.E.IO); + c.srv.reply(&reply, ""); + } + } +}; + +const accept_pause_ms: i64 = 100; + +pub const Listener = struct { + fd: c_int = -1, + tcp_fd: c_int = -1, + tcp_address: ?std.Io.net.IpAddress = null, + quic: if (quic_enabled) ?quic.Listener else void = if (quic_enabled) null else {}, + quic_address: ?std.Io.net.IpAddress = null, + paused_ms: i64 = 0, + path_buf: [sun_path_len]u8 = undefined, + path_len: usize = 0, + conns: [max_conns]Conn = @splat(.{}), + control: [2]c_int = .{ -1, -1 }, + watcher: ?std.Thread = null, + stopping: std.atomic.Value(bool) = .init(false), + watch_lock: std.atomic.Mutex = .unlocked, + watch_fds: [max_conns + 3 + @as(usize, @intFromBool(quic_enabled))]libc.pollfd = undefined, + watch_len: usize = 0, + watch_timeout: c_int = -1, + wake_ctx: ?*anyopaque = null, + wake: ?*const fn (?*anyopaque) void = null, + + pub fn path(l: *const Listener) []const u8 { + return l.path_buf[0..l.path_len]; + } + + fn listenTcp(l: *Listener, address: std.Io.net.IpAddress) !void { + var addr: libc.sockaddr.storage = undefined; + const addr_len = ipSockaddr(address, &addr); + const fd = libc.socket(addr.family, libc.SOCK.STREAM, 0); + if (fd < 0) return error.SocketFailed; + errdefer _ = libc.close(fd); + setCloexec(fd); + setNonblock(fd); + const on: c_int = 1; + if (libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.REUSEADDR, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + if (address == .ip6) { + const v6only = if (darwin) 27 else std.os.linux.IPV6.V6ONLY; + if (libc.setsockopt(fd, libc.IPPROTO.IPV6, v6only, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + } + if (libc.bind(fd, @ptrCast(&addr), addr_len) != 0) return error.BindFailed; + if (libc.listen(fd, max_conns) != 0) return error.ListenFailed; + var actual_len: libc.socklen_t = @sizeOf(libc.sockaddr.storage); + if (libc.getsockname(fd, @ptrCast(&addr), &actual_len) != 0) return error.SocketAddressFailed; + l.tcp_address = sockaddrIp(@ptrCast(&addr)) orelse return error.SocketAddressFailed; + l.tcp_fd = fd; + log.info("serving 9P2000 over TCP on {f}", .{l.tcp_address.?}); + } + + pub fn accept(l: *Listener) void { + if (comptime !supported) return; + for ([_]c_int{ l.fd, l.tcp_fd }) |listener_fd| { + if (listener_fd < 0) continue; + for (0..max_conns + 1) |_| { + const fd = libc.accept(listener_fd, null, null); + if (fd < 0) switch (libc.errno(fd)) { + .AGAIN => break, + .INTR, .CONNABORTED => continue, + else => { + l.paused_ms = nowMs() +| accept_pause_ms; + log.warn("accept failed; pausing the listener for {d} ms", .{accept_pause_ms}); + return; + }, + }; + setCloexec(fd); + setNonblock(fd); + if (listener_fd == l.tcp_fd) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.IPPROTO.TCP, libc.TCP.NODELAY, &on, @sizeOf(c_int)); + } + if (comptime darwin) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); + } + const c = for (&l.conns, 0..) |*cand, i| { + if (!l.live(@intCast(i)) and !cand.draining) break cand; + } else { + log.debug("refusing a connection, all {d} slots busy", .{max_conns}); + _ = libc.close(fd); + continue; + }; + c.fd = fd; + c.draining = false; + c.accepted_ms = nowMs(); + c.srv = .init(.{ + .in = &c.in, + .out = &c.out, + .root = pardes.filesystem.namespace_root, + }); + } + } + if (comptime quic_enabled) { + if (l.quic) |*listener| for (0..max_conns + 1) |_| { + var connection = (listener.accept() catch |err| { + log.warn("QUIC accept failed: {s}", .{@errorName(err)}); + return; + }) orelse break; + const c = for (&l.conns, 0..) |*cand, i| { + if (!l.live(@intCast(i)) and !cand.draining) break cand; + } else { + connection.deinit(); + continue; + }; + c.fd = -1; + c.quic = connection; + c.draining = false; + c.accepted_ms = nowMs(); + c.srv = .init(.{ .in = &c.in, .out = &c.out, .root = pardes.filesystem.namespace_root }); + }; + } + } + + pub const greet_deadline_ms: i64 = 5000; + + pub fn expire(l: *Listener) void { + if (comptime !supported) return; + const now = nowMs(); + if (now == 0) return; + for (&l.conns, 0..) |*c, i| { + if (!l.live(@intCast(i)) or c.srv.msize != 0) continue; + if (now - c.accepted_ms < greet_deadline_ms) continue; + log.debug("slot {d} never sent Tversion; taking it back", .{i}); + l.drop(@intCast(i)); + } + } + + pub fn accepting(l: *const Listener) bool { + if (comptime !supported) return false; + if (l.fd < 0 and l.tcp_fd < 0) return false; + if (l.paused_ms == 0) return true; + const now = nowMs(); + return now == 0 or now >= l.paused_ms; + } + + pub fn nextDue(l: *const Listener) ?i32 { + if (comptime !supported) return null; + const now = nowMs(); + if (now == 0) return null; + var due: ?i64 = null; + if (l.paused_ms > now) due = l.paused_ms; + if (comptime quic_enabled) { + if (l.quic) |*listener| if (listener.nextDue()) |ms| { + const at = now + ms; + due = if (due) |d| @min(d, at) else at; + }; + } + for (&l.conns, 0..) |*c, i| { + if (!l.live(@intCast(i))) continue; + if (comptime quic_enabled) { + if (c.quic) |*connection| if (connection.pending()) return 0; + } + if (c.srv.msize != 0) continue; + const at = c.accepted_ms + greet_deadline_ms; + due = if (due) |d| @min(d, at) else at; + } + const at = due orelse return null; + return @intCast(@max(0, at - now)); + } + + fn nowMs() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); + } + + pub fn fill(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + if (c.srv.dead) return l.drop(i); + const room = c.srv.in.len - c.srv.in_len; + if (room == 0) return; + var buf: [msize]u8 = undefined; + const got: isize = if (quic_enabled and c.quic != null) + @intCast((c.quic.?.read(buf[0..@min(room, buf.len)]) catch return l.drop(i)) orelse return) + else + libc.read(c.fd, &buf, @min(room, buf.len)); + if (got == 0) return l.drop(i); + if (got < 0) return switch (libc.errno(got)) { + .INTR, .AGAIN => {}, + else => l.drop(i), + }; + const n = c.srv.push(buf[0..@intCast(got)]); + if (c.srv.dead) return l.drop(i); + std.debug.assert(n == @as(usize, @intCast(got))); + } + + pub fn flush(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + if (!l.live(i)) return; + while (true) { + const bytes = c.srv.output(); + if (bytes.len == 0) return; + const n: isize = if (quic_enabled and c.quic != null) + @intCast(c.quic.?.write(bytes) catch return l.drop(i)) + else + libc.send(c.fd, bytes.ptr, bytes.len, nosignal); + if (n < 0) switch (libc.errno(n)) { + .INTR => continue, + .AGAIN => return, + else => return l.drop(i), + }; + if (n == 0) return; + c.srv.wrote(@intCast(n)); + } + } + + pub fn owes(l: *const Listener, i: u8) bool { + return l.conns[i].srv.output().len != 0; + } + + pub fn live(l: *const Listener, i: u8) bool { + return l.conns[i].fd >= 0 or (quic_enabled and l.conns[i].quic != null); + } + + pub fn drop(l: *Listener, i: u8) void { + const c = &l.conns[i]; + if (c.fd >= 0) { + _ = libc.close(c.fd); + c.fd = -1; + } + if (comptime quic_enabled) { + if (c.quic) |*connection| { + connection.deinit(); + c.quic = null; + } + } + if (c.draining or c.accepted_ms == 0) return; + c.srv.hangup(); + c.draining = true; + } + + pub const Drained = struct { count: usize = 0, pending: bool = false }; + + pub fn drain(l: *Listener, core: *pardes.Pardes) Drained { + core.fs.socket_path = l.path(); + core.fs.tcp_address = l.tcp_address; + core.fs.quic_address = l.quic_address; + l.expire(); + var result: Drained = .{}; + for (&l.conns, 0..) |*conn, i| { + if (!l.live(@intCast(i)) and !conn.draining) continue; + var count: usize = 0; + while (conn.srv.retry()) |req| { + conn.step(core, req); + l.collectOs(core); + count += 1; + } + while (count < 64) { + const req = conn.srv.next() orelse break; + conn.step(core, req); + l.collectOs(core); + count += 1; + } + result.count += count; + result.pending = result.pending or count >= 64; + if (conn.draining) { + if (count == 0) conn.draining = false else result.pending = true; + } else l.flush(@intCast(i)); + if (comptime quic_enabled) { + if (conn.quic) |*connection| result.pending = result.pending or connection.pending(); + } + } + return result; + } + + pub fn tick(l: *Listener, core: *pardes.Pardes) Drained { + if (comptime quic_enabled) { + if (l.quic) |*listener| listener.events() catch |err| { + log.warn("QUIC listener stopped: {s}", .{@errorName(err)}); + for (&l.conns, 0..) |*conn, i| if (conn.quic != null) l.drop(@intCast(i)); + listener.deinit(); + l.quic = null; + l.quic_address = null; + }; + } + if (l.accepting()) l.accept(); + for (0..max_conns) |i| if (l.live(@intCast(i))) l.fill(@intCast(i)); + const result = l.drain(core); + l.arm(); + return result; + } + + pub fn reset(l: *Listener, core: *pardes.Pardes) void { + for (0..max_conns) |i| l.drop(@intCast(i)); + while (l.drain(core).pending) {} + l.collectOs(core); + for (&l.conns) |*conn| conn.accepted_ms = 0; + l.arm(); + } + + fn collectOs(l: *Listener, core: *pardes.Pardes) void { + var i: usize = 0; + while (i < core.fs.os_paths.items.len) { + const entry = core.fs.os_paths.items[i]; + var held = false; + for (&l.conns, 0..) |*conn, j| { + if (!l.live(@intCast(j)) and !conn.draining) continue; + if (conn.srv.references(entry.node)) { + held = true; + break; + } + } + if (held) { + i += 1; + } else { + core.gpa.free(entry.path); + _ = core.fs.os_paths.swapRemove(i); + } + } + } + + pub fn wakeThread(l: *Listener, ctx: ?*anyopaque, wake: *const fn (?*anyopaque) void) !void { + if (l.watcher != null) return; + if (libc.pipe(&l.control) != 0) return error.PipeFailed; + errdefer { + _ = libc.close(l.control[0]); + _ = libc.close(l.control[1]); + l.control = .{ -1, -1 }; + } + for (l.control) |fd| { + setCloexec(fd); + setNonblock(fd); + } + l.wake_ctx = ctx; + l.wake = wake; + l.arm(); + l.watcher = try std.Thread.spawn(.{}, watch, .{l}); + } + + fn arm(l: *Listener) void { + if (l.control[1] < 0) return; + while (!l.watch_lock.tryLock()) std.atomic.spinLoopHint(); + l.watch_fds[0] = .{ .fd = l.control[0], .events = @intCast(libc.POLL.IN), .revents = 0 }; + l.watch_len = 1; + if (l.accepting()) { + for ([_]c_int{ l.fd, l.tcp_fd }) |fd| { + if (fd < 0) continue; + l.watch_fds[l.watch_len] = .{ .fd = fd, .events = @intCast(libc.POLL.IN), .revents = 0 }; + l.watch_len += 1; + } + } + if (comptime quic_enabled) { + if (l.quic) |*listener| { + l.watch_fds[l.watch_len] = listener.poll(); + l.watch_len += 1; + } + } + for (0..max_conns) |i| { + if (l.conns[i].fd < 0) continue; + l.watch_fds[l.watch_len] = .{ + .fd = l.conns[i].fd, + .events = @as(i16, @intCast(libc.POLL.IN)) | if (l.owes(@intCast(i))) @as(i16, @intCast(libc.POLL.OUT)) else 0, + .revents = 0, + }; + l.watch_len += 1; + } + l.watch_timeout = l.nextDue() orelse -1; + l.watch_lock.unlock(); + _ = libc.write(l.control[1], "w", 1); + } + + fn watch(l: *Listener) void { + var notified = false; + while (!l.stopping.load(.acquire)) { + var fds: [max_conns + 3 + @as(usize, @intFromBool(quic_enabled))]libc.pollfd = undefined; + while (!l.watch_lock.tryLock()) std.atomic.spinLoopHint(); + const len = if (notified) 1 else l.watch_len; + @memcpy(fds[0..len], l.watch_fds[0..len]); + const timeout = if (notified) -1 else l.watch_timeout; + l.watch_lock.unlock(); + const ready = libc.poll(&fds, @intCast(len), timeout); + if (ready < 0) continue; + if (fds[0].revents != 0) { + var buf: [64]u8 = undefined; + while (libc.read(l.control[0], &buf, buf.len) > 0) {} + notified = false; + continue; + } + if (!l.stopping.load(.acquire)) l.wake.?(l.wake_ctx); + notified = true; + } + } + + pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { + if (l.watcher) |thread| { + l.stopping.store(true, .release); + _ = libc.write(l.control[1], "q", 1); + thread.join(); + for (l.control) |fd| _ = libc.close(fd); + } + for (0..max_conns) |i| l.drop(@intCast(i)); + if (comptime quic_enabled) { + if (l.quic) |*listener| listener.deinit(); + } + if (l.tcp_fd >= 0) _ = libc.close(l.tcp_fd); + if (l.fd >= 0) { + _ = libc.close(l.fd); + l.fd = -1; + var z: [sun_path_len:0]u8 = undefined; + @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); + z[l.path_len] = 0; + _ = libc.unlink(z[0..l.path_len :0]); + } + gpa.destroy(l); + } +}; + +pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { + if (name.len == 0) return null; + if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; + return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; +} + +pub fn listen(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8, tcp_dial: ?[]const u8, quic_dial: ?[]const u8) ?*Listener { + if (comptime !supported) return null; + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf) orelse { + log.warn("no runtime directory for the socket", .{}); + return null; + }; + if (!ensureSocketDir(dir)) return null; + const l = gpa.create(Listener) catch return null; + l.* = .{}; + const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { + gpa.destroy(l); + return null; + }; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) { + gpa.destroy(l); + return null; + } + setCloexec(fd); + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + const bind_error = libc.errno(-1); + const io = std.Io.Threaded.global_single_threaded.io(); + const existing = std.Io.Dir.cwd().statFile(io, p, .{ .follow_symlinks = false }) catch null; + if (bind_error != .ADDRINUSE or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) { + log.warn("something is already listening on {s}", .{p}); + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + if (libc.unlink(p) != 0 or libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + } + if (libc.chmod(p, 0o600) != 0 or libc.listen(fd, max_conns) != 0) { + _ = libc.close(fd); + _ = libc.unlink(p); + gpa.destroy(l); + return null; + } + setNonblock(fd); + l.fd = fd; + l.path_len = p.len; + if (tcp_dial) |dial| { + if (!std.mem.startsWith(u8, dial, "tcp!")) { + l.deinit(gpa); + return null; + } + const address = networkAddress(dial, true) catch { + log.warn("invalid TCP address {s}", .{dial}); + l.deinit(gpa); + return null; + }; + l.listenTcp(address) catch |err| { + log.warn("cannot listen on {s}: {s}", .{ dial, @errorName(err) }); + l.deinit(gpa); + return null; + }; + } + if (quic_dial) |dial| { + if (comptime quic_enabled) { + if (!std.mem.startsWith(u8, dial, "quic!")) { + l.deinit(gpa); + return null; + } + const address = networkAddress(dial, true) catch { + log.warn("invalid QUIC address {s}", .{dial}); + l.deinit(gpa); + return null; + }; + l.quic = quic.Listener.init(address) catch |err| { + log.warn("cannot listen on {s}: {s}", .{ dial, @errorName(err) }); + l.deinit(gpa); + return null; + }; + l.quic_address = l.quic.?.address; + log.info("serving 9P2000 over QUIC on {f}", .{l.quic_address.?}); + } else { + log.warn("QUIC is unavailable in this build", .{}); + l.deinit(gpa); + return null; + } + } + log.info("serving 9P2000 on {s}", .{p}); + return l; +} + +fn alive(path: [:0]const u8) bool { + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return true; + defer _ = libc.close(fd); + setCloexec(fd); + setNonblock(fd); + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) return true; + return libc.errno(-1) != .CONNREFUSED; +} + +fn setNonblock(fd: c_int) void { + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return; + var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); + o.NONBLOCK = true; + _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); +} + +const nosignal: u32 = if (darwin) 0 else libc.MSG.NOSIGNAL; + +const testing = std.testing; + +test "the socket name is a third prefix in the shared directory" { + var buf: [sun_path_len]u8 = undefined; + const p = socketPath(&buf, "/run/user/1000", "t9srv").?; + try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p); + try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-")); +} + +test "a name that is not one path component is no address at all" { + var buf: [sun_path_len]u8 = undefined; + try testing.expect(socketPath(&buf, "/run", "") == null); + try testing.expect(socketPath(&buf, "/run", "a/b") == null); + try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); +} + +test "one connection's buffers are sized from the one msize constant" { + try testing.expect(msize >= ninep.min_msize); + const c: Conn = .{}; + try testing.expectEqual(@as(usize, msize), c.in.len); + try testing.expectEqual(@as(usize, 2 * msize), c.out.len); +} + +test "TCP addresses are numeric and normalize mapped IPv4" { + const loopback = try networkAddress("tcp!127.0.0.1!5640", false); + try testing.expectEqualDeep(loopback, try networkAddress("tcp!::ffff:127.0.0.1!5640", false)); + try testing.expectEqualDeep(loopback, try networkAddress("tcp!::ffff:7f00:1!5640", false)); + try testing.expectEqualDeep(try networkAddress("tcp!::1!5640", false), try networkAddress("tcp!0:0:0:0:0:0:0:1!5640", false)); + try testing.expectEqual(@as(u16, 0), (try networkAddress("tcp!127.0.0.1!0", true)).getPort()); + for ([_][]const u8{ "tcp!localhost!5640", "tcp!127.0.0.1!0", "tcp!127.0.0.1!-1", "tcp!127.0.0.1!65536", "tcp!127.0.0.1!", "tcp!!5640" }) |dial| + try testing.expectError(error.BadDial, networkAddress(dial, false)); + try Client.validateDial("tcp!127.0.0.1!5640"); + try Client.validateDial("/tmp/pardes-owned.sock"); + try Client.validateDial("unix!/tmp/pardes-owned.sock"); + try testing.expectError(error.BadDial, Client.validateDial("unix!work")); + try testing.expectError(error.BadDial, Client.validateDial("unix!")); + try testing.expectError(error.BadDial, Client.validateDial("unix!/tmp/a\x00b")); + try testing.expectError(error.BadDial, Client.validateDial("tcp!localhost!5640")); + try testing.expectError(error.BadDial, Client.validateDial("/tmp/a\x00b")); + if (quic_enabled) { + try Client.validateDial("quic!127.0.0.1!5640"); + } else try testing.expectError(error.QuicUnavailable, Client.validateDial("quic!127.0.0.1!5640")); +} + +test "same-session TCP mounts compare canonical endpoints and local wildcard destinations" { + if (comptime !supported) return error.SkipZigTest; + const Case = struct { bound: []const u8, dial: []const u8, same: bool }; + for ([_]Case{ + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!::ffff:127.0.0.1!5640", .same = true }, + .{ .bound = "tcp!::ffff:127.0.0.1!5640", .dial = "tcp!127.0.0.1!5640", .same = true }, + .{ .bound = "tcp!::1!5640", .dial = "tcp!0:0:0:0:0:0:0:1!5640", .same = true }, + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!0.0.0.0!5640", .same = true }, + .{ .bound = "tcp!::1!5640", .dial = "tcp!::!5640", .same = true }, + .{ .bound = "tcp!0.0.0.0!5640", .dial = "tcp!127.0.0.2!5640", .same = true }, + .{ .bound = "tcp!::!5640", .dial = "tcp!::1!5640", .same = true }, + .{ .bound = "tcp!127.0.0.1!5640", .dial = "tcp!127.0.0.1!5641", .same = false }, + .{ .bound = "tcp!0.0.0.0!5640", .dial = "tcp!192.0.2.1!5640", .same = false }, + .{ .bound = "tcp!::!5640", .dial = "tcp!2001:db8::1!5640", .same = false }, + .{ .bound = "tcp!::!5640", .dial = "tcp!127.0.0.1!5640", .same = false }, + }) |c| try testing.expectEqual(c.same, Client.sameSession(c.dial, "", try networkAddress(c.bound, true), null)); + try testing.expect(Client.sameSession("/tmp/pardes-owned.sock", "/tmp/pardes-owned.sock", null, null)); + try testing.expect(Client.sameSession("unix!/tmp/pardes-owned.sock", "/tmp/pardes-owned.sock", null, null)); + try testing.expect(!Client.sameSession("tcp!127.0.0.1!5640", "/tmp/pardes-owned.sock", null, null)); + if (quic_enabled) { + const endpoint = try networkAddress("quic!127.0.0.1!5640", false); + try testing.expect(Client.sameSession("quic!::ffff:127.0.0.1!5640", "", null, endpoint)); + try testing.expect(!Client.sameSession("tcp!127.0.0.1!5640", "", null, endpoint)); + try testing.expect(!Client.sameSession("quic!127.0.0.1!5640", "", endpoint, null)); + } +} + +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; +extern "c" fn rmdir(path: [*:0]const u8) c_int; + +test "Unix TCP and QUIC share one listener through reads writes reconnects and reset" { + if (comptime !supported) return error.SkipZigTest; + const gpa = testing.allocator; + var directory: [64:0]u8 = undefined; + _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-tcp-XXXXXX", .{}, 0); + if (mkdtemp(&directory) == null) return error.TempDirectoryFailed; + defer _ = rmdir(&directory); + const old_runtime = if (libc.getenv("XDG_RUNTIME_DIR")) |v| try gpa.dupeZ(u8, std.mem.span(v)) else null; + defer { + if (old_runtime) |v| { + _ = setenv("XDG_RUNTIME_DIR", v, 1); + gpa.free(v); + } else _ = unsetenv("XDG_RUNTIME_DIR"); + } + try testing.expectEqual(@as(c_int, 0), setenv("XDG_RUNTIME_DIR", &directory, 1)); + const replacement = try gpa.alloc(u8, 3 * msize + 27); + defer gpa.free(replacement); + @memset(replacement, 'x'); + @memcpy(replacement[0.."changed café λ\n".len], "changed café λ\n"); + replacement[replacement.len - 1] = '\n'; + + const Worker = struct { + dial: []const u8, + body_path: []const u8, + expected: []const u8, + replacement: []const u8, + done: std.atomic.Value(bool) = .init(false), + failure: ?anyerror = null, + + fn run(w: *@This()) void { + defer w.done.store(true, .release); + w.check() catch |err| { + w.failure = err; + }; + } + + fn check(w: *@This()) !void { + const before = try Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len); + defer testing.allocator.free(before); + try testing.expectEqualStrings(w.expected, before); + try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, w.body_path, w.body_path, w.expected.len - 1)); + const listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", 128); + defer testing.allocator.free(listing); + const exact_listing = try Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len); + defer testing.allocator.free(exact_listing); + try testing.expectEqualStrings(listing, exact_listing); + try testing.expectError(error.FileTooLarge, Client.readLimit(testing.allocator, w.dial, "/self/pane", "/self/pane", listing.len - 1)); + try Client.write(testing.allocator, w.dial, w.body_path, w.replacement); + const after = try Client.read(testing.allocator, w.dial, w.body_path, w.body_path); + defer testing.allocator.free(after); + try testing.expectEqualStrings(w.replacement, after); + const screen = try Client.read(testing.allocator, w.dial, "/self/screen", "/self/screen"); + defer testing.allocator.free(screen); + const parsed = try std.json.parseFromSlice(struct { cols: u16, rows: u16 }, testing.allocator, screen, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + try testing.expectEqual(@as(u16, 40), parsed.value.cols); + try testing.expectEqual(@as(u16, 12), parsed.value.rows); + } + }; + + const protocols: []const []const u8 = if (quic_enabled) &.{ "tcp", "quic" } else &.{"tcp"}; + for (protocols) |protocol| for ([_][]const u8{ "127.0.0.1", "::1" }) |host| { + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("initial\n"); + while (p.nextEffect()) |_| {} + var bind_buf: [64]u8 = undefined; + const bind = try std.fmt.bufPrint(&bind_buf, "{s}!{s}!0", .{ protocol, host }); + const tcp = std.mem.eql(u8, protocol, "tcp"); + const l = listen(gpa, "roundtrip", "", if (tcp) bind else null, if (tcp) null else bind) orelse return error.ListenFailed; + defer { + l.reset(p); + l.deinit(gpa); + } + try testing.expect(l.fd >= 0); + try testing.expectEqual(tcp, l.tcp_fd >= 0); + try testing.expectEqual(@as(usize, 4), l.conns.len); + try testing.expect(l.watcher == null); + const port = (if (tcp) l.tcp_address else l.quic_address).?.getPort(); + try testing.expect(port != 0); + var dial_buf: [64]u8 = undefined; + const network_dial = try std.fmt.bufPrint(&dial_buf, "{s}!{s}!{d}", .{ protocol, host, port }); + var body_buf: [64]u8 = undefined; + const body = try std.fmt.bufPrint(&body_buf, "/self/pane/{d}/body", .{pane.serial}); + for ([_][]const u8{ network_dial, l.path(), network_dial }, 0..) |dial, attempt| { + var worker: Worker = .{ .dial = dial, .body_path = body, .expected = if (attempt == 0) "initial\n" else replacement, .replacement = replacement }; + const thread = try std.Thread.spawn(.{}, Worker.run, .{&worker}); + defer thread.join(); + const deadline = Client.nowMs() + 3 * Client.budget_ms; + while (!worker.done.load(.acquire) and Client.nowMs() < deadline) { + _ = l.tick(p); + Client.nap(1); + } + try testing.expect(worker.done.load(.acquire)); + if (worker.failure) |err| return err; + const unix_fd = l.fd; + const tcp_fd = l.tcp_fd; + l.reset(p); + try testing.expectEqual(unix_fd, l.fd); + try testing.expectEqual(tcp_fd, l.tcp_fd); + for (&l.conns, 0..) |conn, i| try testing.expect(!l.live(@intCast(i)) and !conn.draining); + for (p.fs.snapshots) |snapshot| try testing.expect(snapshot.node == 0); + } + }; +} + +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; + +pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listener { + var name: [16]u8 = undefined; + const fallback = std.fmt.bufPrint(&name, "{d}", .{@as(u32, @intCast(libc.getpid()))}) catch unreachable; + const listener = listen(gpa, core.opts.ninep_name, fallback, core.opts.ninep_tcp, core.opts.ninep_quic) orelse { + core.reportError(0, "9p listener", error.ListenFailed); + return null; + }; + core.fs.socket_path = listener.path(); + core.fs.tcp_address = listener.tcp_address; + core.fs.quic_address = listener.quic_address; + return listener; +} + +pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, forward_look: bool) void { + _ = unsetenv("PARDES_FORWARD_LOOK"); + if (listener) |l| exporting: { + var sock: [sun_path_len]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{l.path()}, 0) catch break :exporting; + var buf: [16]u8 = undefined; + const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch break :exporting; + if (setenv("PARDES_9P", path, 1) != 0) break :exporting; + if (setenv("PARDES_PANE", id, 1) != 0) break :exporting; + if (setenv("PARDES_FORWARD_LOOK", if (forward_look) "1" else "0", 1) == 0) return; + } + _ = unsetenv("PARDES_9P"); + _ = unsetenv("PARDES_PANE"); + _ = setenv("PARDES_FORWARD_LOOK", "0", 1); +} + +test "9P shell environment preserves identity when nested Look forwarding is disabled" { + const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }; + var saved: [names.len]?[:0]u8 = @splat(null); + for (names, &saved) |name, *value| { + if (libc.getenv(name)) |old| value.* = try testing.allocator.dupeZ(u8, std.mem.span(old)); + } + defer for (names, saved) |name, value| { + if (value) |old| { + _ = setenv(name, old, 1); + testing.allocator.free(old); + } else _ = unsetenv(name); + }; + + var dir: [sun_path_len:0]u8 = undefined; + _ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1); + try testing.expectEqualStrings("/run/user/1000", socketDir(&dir).?); + _ = unsetenv("XDG_RUNTIME_DIR"); + _ = setenv("HOME", "/home/example", 1); + try testing.expectEqualStrings("/home/example/.local/state/pardes", socketDir(&dir).?); + _ = unsetenv("HOME"); + try testing.expect(socketDir(&dir) == null); + + const listener = try testing.allocator.create(Listener); + defer testing.allocator.destroy(listener); + listener.* = .{}; + const path = "/tmp/pardes-example.sock"; + @memcpy(listener.path_buf[0..path.len], path); + listener.path_len = path.len; + exportPaneEnv(listener, 7, false); + try testing.expectEqualStrings(path, std.mem.span(libc.getenv("PARDES_9P").?)); + try testing.expectEqualStrings("7", std.mem.span(libc.getenv("PARDES_PANE").?)); + try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + exportPaneEnv(listener, 8, true); + try testing.expectEqualStrings("8", std.mem.span(libc.getenv("PARDES_PANE").?)); + try testing.expectEqualStrings("1", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + exportPaneEnv(null, 0, false); + try testing.expect(libc.getenv("PARDES_9P") == null); + try testing.expect(libc.getenv("PARDES_PANE") == null); + try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); +} + +pub const Client = struct { + pub const budget_ms: i64 = 2000; + + pub const max_depth: usize = 2 * ninep.max_welem; + + const uname = "pardes"; + + const Dial = union(enum) { unix: [:0]const u8, tcp: std.Io.net.IpAddress, quic: std.Io.net.IpAddress }; + + pub const Error = error{ + PathTooDeep, + BadDial, + Dial, + Hangup, + Timeout, + Botch, + Remote, + IsDirectory, + NotFound, + FileTooLarge, + QuicUnavailable, + }; + + pub fn read(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, display_path: []const u8) ![]u8 { + return readLimit(gpa, dial, path, display_path, limits.max_file_bytes); + } + + pub fn readLimit(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, display_path: []const u8, max_bytes: usize) ![]u8 { + if (comptime !supported) return error.Unsupported; + var names: [max_depth][]const u8 = undefined; + const n = try elements(path, &names); + var sock_buf: [sun_path_len]u8 = undefined; + const sock = try resolve(&sock_buf, dial); + var remote: RemoteError = .{}; + return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes)); + } + + pub fn write(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, bytes: []const u8) !void { + if (comptime !supported) return error.Unsupported; + var names: [max_depth][]const u8 = undefined; + const n = try elements(path, &names); + var sock_buf: [sun_path_len]u8 = undefined; + const sock = try resolve(&sock_buf, dial); + var remote: RemoteError = .{}; + const result = try fetchBytes(gpa, sock, names[0..n], &remote, bytes, path, limits.max_file_bytes); + gpa.free(result); + } + + const RemoteError = struct { + buf: [ninep.errmax]u8 = undefined, + len: usize = 0, + + fn set(r: *RemoteError, msg: []const u8) error{Remote} { + r.len = @min(msg.len, r.buf.len); + @memcpy(r.buf[0..r.len], msg[0..r.len]); + return error.Remote; + } + }; + + fn elements(path: []const u8, out: *[max_depth][]const u8) Error!usize { + var n: usize = 0; + var it = std.mem.tokenizeScalar(u8, path, '/'); + while (it.next()) |name| { + if (n == out.len) return Error.PathTooDeep; + out[n] = name; + n += 1; + } + return n; + } + + fn resolve(buf: *[sun_path_len]u8, dial: []const u8) error{ BadDial, QuicUnavailable }!Dial { + if (dial.len == 0) return error.BadDial; + if (std.mem.startsWith(u8, dial, "tcp!")) return .{ .tcp = try networkAddress(dial, false) }; + if (std.mem.startsWith(u8, dial, "quic!")) { + if (comptime !quic_enabled) return error.QuicUnavailable; + return .{ .quic = try networkAddress(dial, false) }; + } + const explicit_unix = std.mem.startsWith(u8, dial, "unix!"); + const path = if (explicit_unix) dial[5..] else dial; + if (explicit_unix and !std.mem.startsWith(u8, path, "/")) return error.BadDial; + if (std.mem.indexOfScalar(u8, path, '/') != null) { + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.BadDial; + return .{ .unix = std.fmt.bufPrintSentinel(buf, "{s}", .{path}, 0) catch return error.BadDial }; + } + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf) orelse return error.BadDial; + return .{ .unix = socketPath(buf, dir, dial) orelse return error.BadDial }; + } + + pub fn validateDial(dial: []const u8) error{ BadDial, QuicUnavailable }!void { + var buf: [sun_path_len]u8 = undefined; + _ = try resolve(&buf, dial); + } + + pub fn sameSession(dial: []const u8, socket_path: []const u8, tcp_address: ?std.Io.net.IpAddress, quic_address: ?std.Io.net.IpAddress) bool { + if (comptime !supported) return false; + var buf: [sun_path_len]u8 = undefined; + const address = resolve(&buf, dial) catch return false; + switch (address) { + .unix => |path| return socket_path.len != 0 and std.mem.eql(u8, path, socket_path), + .tcp, .quic => |destination| { + var ip = destination; + switch (ip) { + .ip4 => |v4| if (std.mem.allEqual(u8, &v4.bytes, 0)) { + ip = .{ .ip4 = .loopback(v4.port) }; + }, + .ip6 => |v6| if (std.mem.allEqual(u8, &v6.bytes, 0)) { + ip = .{ .ip6 = .loopback(v6.port) }; + }, + } + const bound = canonicalIp((if (address == .tcp) tcp_address else quic_address) orelse return false); + if (ip.getPort() != bound.getPort() or @as(std.Io.net.IpAddress.Family, ip) != @as(std.Io.net.IpAddress.Family, bound)) return false; + if (ip.eql(&bound)) return true; + const wildcard = switch (bound) { + .ip4 => |v4| std.mem.allEqual(u8, &v4.bytes, 0), + .ip6 => |v6| std.mem.allEqual(u8, &v6.bytes, 0), + }; + if (wildcard) return localIp(ip); + return false; + }, + } + } + + const Session = struct { + fd: c_int, + quic: if (quic_enabled) ?quic.Connection else void = if (quic_enabled) null else {}, + deadline: i64, + display_path: []const u8, + cl: ninep.Client = undefined, + in: [msize]u8 = undefined, + out: [msize]u8 = undefined, + stage: [msize]u8 = undefined, + + fn wait(s: *Session, events: i16) Error!void { + while (true) { + const left = s.deadline - nowMs(); + if (left <= 0) return Error.Timeout; + if (comptime quic_enabled) { + if (s.quic) |*connection| { + var fds = [1]libc.pollfd{connection.poll().?}; + const timeout = @min(left, connection.nextDue() orelse budget_ms); + const ready = libc.poll(&fds, 1, @intCast(timeout)); + if (ready < 0) { + if (libc.errno(ready) == .INTR) continue; + return Error.Hangup; + } + if (nowMs() >= s.deadline) return Error.Timeout; + if (fds[0].revents & @as(i16, @intCast(libc.POLL.NVAL)) != 0) return Error.Hangup; + connection.events() catch return Error.Hangup; + return; + } + } + var fds = [1]libc.pollfd{.{ .fd = s.fd, .events = events, .revents = 0 }}; + const ready = libc.poll(&fds, 1, @intCast(@min(left, budget_ms))); + if (ready < 0) { + if (libc.errno(ready) == .INTR) continue; + return Error.Hangup; + } + if (ready == 0 or nowMs() >= s.deadline) return Error.Timeout; + if (fds[0].revents & events != 0) return; + return Error.Hangup; + } + } + + fn flush(s: *Session) Error!void { + while (s.cl.output().len != 0) { + if (nowMs() >= s.deadline) return Error.Timeout; + const bytes = s.cl.output(); + if (comptime quic_enabled) { + if (s.quic) |*connection| { + const sent = connection.write(bytes) catch return Error.Hangup; + if (sent == 0) { + try s.wait(poll_out); + continue; + } + s.cl.wrote(sent); + continue; + } + } + try s.wait(poll_out); + const sent = libc.send(s.fd, bytes.ptr, bytes.len, nosignal); + if (sent < 0) switch (libc.errno(sent)) { + .INTR, .AGAIN => continue, + else => return Error.Hangup, + }; + if (sent == 0) return Error.Hangup; + s.cl.wrote(@intCast(sent)); + } + } + + fn settle(s: *Session) Error!ninep.Client.Done { + while (true) { + if (nowMs() >= s.deadline) return Error.Timeout; + try s.flush(); + if (s.cl.take()) |done| return done; + if (s.cl.dead) return Error.Botch; + const room = s.cl.in.len - s.cl.in_len; + if (room == 0) return Error.Botch; + if (comptime quic_enabled) { + if (s.quic) |*connection| { + const got = (connection.read(s.stage[0..@min(room, s.stage.len)]) catch return Error.Hangup) orelse { + try s.wait(poll_in); + continue; + }; + if (got == 0) return Error.Hangup; + const n = s.cl.push(s.stage[0..got]); + std.debug.assert(n == got); + continue; + } + } + try s.wait(poll_in); + const got = libc.read(s.fd, &s.stage, @min(room, s.stage.len)); + if (got == 0) return Error.Hangup; + if (got < 0) switch (libc.errno(got)) { + .INTR, .AGAIN => continue, + else => return Error.Hangup, + }; + const n = s.cl.push(s.stage[0..@intCast(got)]); + std.debug.assert(n == @as(usize, @intCast(got))); + } + } + + fn ask(s: *Session, req: ninep.Client.Request, remote: *RemoteError) Error!ninep.Client.Result { + _ = s.cl.submit(req) catch return Error.Botch; + const done = try s.settle(); + if (done.result == .fail) return remote.set(done.result.fail); + if (std.mem.eql(u8, @tagName(done.result), @tagName(std.meta.activeTag(req)))) return done.result; + return Error.Botch; + } + + fn drop(s: *Session, fid: u32) void { + _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; + _ = s.settle() catch {}; + } + + fn dropNoWait(s: *Session, fid: u32) void { + _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; + s.flush() catch {}; + } + }; + + fn transact( + s: *Session, + names: []const []const u8, + out: *std.Io.Writer.Allocating, + remote: *RemoteError, + write_bytes: ?[]const u8, + read_limit: usize, + ) !void { + _ = try s.ask(.{ .version = .{} }, remote); + if (s.cl.msize == 0) return Error.Botch; + + const root: u32 = 0; + var here = (try s.ask(.{ .attach = .{ .fid = root, .uname = uname } }, remote)).attach; + var cur: u32 = root; + var next: u32 = 1; + + var i: usize = 0; + while (i < names.len) { + const n = @min(ninep.max_welem, names.len - i); + const w = (try s.ask(.{ .walk = .{ + .fid = cur, + .newfid = next, + .names = names[i..][0..n], + } }, remote)).walk; + if (w.nwqid != n) return Error.NotFound; + here = w.wqid[n - 1]; + if (cur != root) s.drop(cur); + cur = next; + next = if (next == 1) 2 else 1; + i += n; + } + defer s.dropNoWait(cur); + + const directory = here.type & ninep.qtdir != 0; + + const mode: u8 = if (write_bytes != null) ninep.owrite else ninep.oread; + const truncate = write_bytes != null and names.len > 0 and + (std.mem.eql(u8, names[0], "os") or std.mem.eql(u8, names[names.len - 1], "body")); + _ = try s.ask(.{ .open = .{ .fid = cur, .mode = mode | if (truncate) ninep.otrunc else 0 } }, remote); + if (write_bytes) |bytes| { + if (directory) return Error.IsDirectory; + var written: usize = 0; + while (written < bytes.len) { + const chunk = bytes[written..][0..@min(bytes.len - written, s.cl.maxWrite())]; + const count = (try s.ask(.{ .write = .{ .fid = cur, .offset = written, .data = chunk } }, remote)).write; + if (count == 0 or count > chunk.len) return Error.Botch; + written += count; + } + return; + } + + const max_bytes: u64 = @min(read_limit, @as(usize, if (directory) limits.max_stream_bytes else limits.max_file_bytes)); + const wire_limit: u64 = if (directory) limits.max_stream_bytes else max_bytes; + var off: u64 = 0; + while (true) { + const want: u32 = @intCast(@min(@as(u64, s.cl.maxRead()), wire_limit + 1 - off)); + const data = (try s.ask(.{ .read = .{ .fid = cur, .offset = off, .count = want } }, remote)).read; + if (data.len == 0) return; + if (off + data.len > wire_limit) return Error.FileTooLarge; + if (directory) { + var pos: usize = 0; + while (pos < data.len) { + if (data.len - pos < 2) return Error.Botch; + const len: usize = 2 + @as(usize, std.mem.readInt(u16, data[pos..][0..2], .little)); + if (len > data.len - pos) return Error.Botch; + const entry = ninep.Stat.decode(data[pos..][0..len]) catch return Error.Botch; + const display_dir = std.mem.trimEnd(u8, s.display_path, "/"); + const row_len = display_dir.len + entry.name.len + 2 + @as(usize, @intFromBool(entry.qid.type & ninep.qtdir != 0)); + if (row_len > max_bytes - out.written().len) return Error.FileTooLarge; + try out.writer.print("{s}/{s}", .{ display_dir, entry.name }); + if (entry.qid.type & ninep.qtdir != 0) try out.writer.writeByte('/'); + try out.writer.writeByte('\n'); + pos += len; + } + } else try out.writer.writeAll(data); + off += data.len; + } + } + + fn fetchBytes( + gpa: std.mem.Allocator, + sock: Dial, + names: []const []const u8, + remote: *RemoteError, + write_bytes: ?[]const u8, + display_path: []const u8, + read_limit: usize, + ) ![]u8 { + if (comptime !supported) return Error.Dial; + const deadline = nowMs() +| budget_ms; + const s = try gpa.create(Session); + s.* = .{ .fd = -1, .deadline = deadline, .display_path = display_path }; + defer { + if (quic_enabled and s.quic != null) { + s.quic.?.deinit(); + } else if (s.fd >= 0) _ = libc.close(s.fd); + gpa.destroy(s); + } + if (sock == .quic) { + if (comptime quic_enabled) { + s.quic = quic.Connection.dial(sock.quic) catch return Error.Dial; + s.fd = s.quic.?.fd; + } else return Error.QuicUnavailable; + } else s.fd = try connect(sock, deadline); + s.cl = .init(.{ .in = &s.in, .out = &s.out }); + + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + try transact(s, names, &out, remote, write_bytes, read_limit); + return out.toOwnedSlice(); + } + + fn connect(sock: Dial, deadline: i64) Error!c_int { + var addr: libc.sockaddr.storage = undefined; + const addr_len: libc.socklen_t = switch (sock) { + .unix => |path| blk: { + if (path.len + 1 > sun_path_len) return Error.BadDial; + const un: *libc.sockaddr.un = @ptrCast(&addr); + un.* = .{ .path = @splat(0) }; + @memcpy(un.path[0 .. path.len + 1], path[0 .. path.len + 1]); + break :blk @sizeOf(libc.sockaddr.un); + }, + .tcp => |ip| ipSockaddr(ip, &addr), + .quic => return Error.QuicUnavailable, + }; + const fd = libc.socket(addr.family, libc.SOCK.STREAM, 0); + if (fd < 0) return Error.Dial; + setCloexec(fd); + setNonblock(fd); + errdefer _ = libc.close(fd); + if (sock == .tcp) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.IPPROTO.TCP, libc.TCP.NODELAY, &on, @sizeOf(c_int)); + } + while (true) { + if (libc.connect(fd, @ptrCast(&addr), addr_len) == 0) break; + switch (libc._errno().*) { + @intFromEnum(libc.E.AGAIN), @intFromEnum(libc.E.INTR) => { + if (nowMs() >= deadline) return Error.Dial; + nap(2); + }, + @intFromEnum(libc.E.INPROGRESS), @intFromEnum(libc.E.ALREADY) => { + const left = deadline - nowMs(); + if (left <= 0) return Error.Dial; + var pfd: [1]libc.pollfd = .{.{ .fd = fd, .events = poll_out, .revents = 0 }}; + if (libc.poll(&pfd, 1, @intCast(@min(left, 1000))) <= 0) continue; + var err: c_int = 0; + var len: libc.socklen_t = @sizeOf(c_int); + if (libc.getsockopt(fd, libc.SOL.SOCKET, libc.SO.ERROR, @ptrCast(&err), &len) != 0) + return Error.Dial; + if (err == 0) break; + return Error.Dial; + }, + @intFromEnum(libc.E.ISCONN) => break, + else => return Error.Dial, + } + } + if (comptime darwin) { + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); + } + return fd; + } + + fn nowMs() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return std.math.maxInt(i64); + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); + } + + const poll_in: i16 = @intCast(libc.POLL.IN); + const poll_out: i16 = @intCast(libc.POLL.OUT); + + fn nap(ms: c_int) void { + _ = libc.poll(&[0]libc.pollfd{}, 0, ms); + } + + test "a path becomes walk elements, normalised the way a shell would" { + var out: [max_depth][]const u8 = undefined; + try testing.expectEqual(@as(usize, 4), try elements("/self/pane/1/body", &out)); + try testing.expectEqualStrings("self", out[0]); + try testing.expectEqualStrings("pane", out[1]); + try testing.expectEqualStrings("1", out[2]); + try testing.expectEqualStrings("body", out[3]); + + try testing.expectEqual(@as(usize, 4), try elements("self/pane/1/body", &out)); + try testing.expectEqual(@as(usize, 4), try elements("//self//pane//1//body//", &out)); + try testing.expectEqual(@as(usize, 2), try elements("/self/index", &out)); + + try testing.expectEqual(@as(usize, 0), try elements("/", &out)); + + var deep: [8 * max_depth]u8 = @splat('/'); + for (0..max_depth + 1) |i| deep[i * 2 + 1] = 'a'; + try testing.expectError(Error.PathTooDeep, elements(deep[0 .. (max_depth + 1) * 2], &out)); + } + + test "a bare dial resolves to the socket --9p binds, and a path is taken as given" { + if (comptime !supported) return error.SkipZigTest; + var buf: [sun_path_len]u8 = undefined; + + const named = (try resolve(&buf, "work")).unix; + try testing.expect(std.mem.endsWith(u8, named, "/pardes-9p-work.sock")); + var expect: [sun_path_len]u8 = undefined; + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = socketDir(&dir_buf).?; + try testing.expectEqualStrings(socketPath(&expect, dir, "work").?, named); + + const path = (try resolve(&buf, "/tmp/somewhere.sock")).unix; + try testing.expectEqualStrings("/tmp/somewhere.sock", path); + try testing.expectEqualStrings("/tmp/somewhere.sock", (try resolve(&buf, "unix!/tmp/somewhere.sock")).unix); + + try testing.expectError(error.BadDial, resolve(&buf, "")); + try testing.expectError(error.BadDial, resolve(&buf, "/tmp/a\x00b")); + } + + test "a dial with nothing listening is one error and not a wait" { + if (comptime !supported) return error.SkipZigTest; + var names: [max_depth][]const u8 = undefined; + const n = try elements("/self/pane/1/body", &names); + var remote: RemoteError = .{}; + const before = nowMs(); + try testing.expectError( + Error.Dial, + fetchBytes(testing.allocator, .{ .unix = "/tmp/pardes-9p-no-such-socket.sock" }, names[0..n], &remote, null, "/self/pane/1/body", limits.max_file_bytes), + ); + try testing.expect(nowMs() - before < budget_ms); + } + + test "one fetch has three msize buffers and bounded transport metadata" { + const transport_bytes = if (quic_enabled) @sizeOf(?quic.Connection) else 0; + try testing.expectEqual(@as(usize, msize), @as(usize, (Session{ .fd = -1, .deadline = 0, .display_path = "" }).in.len)); + try testing.expect(transport_bytes <= 64); + try testing.expect(@sizeOf(Session) <= 3 * msize + 256 + transport_bytes); + try testing.expect(@sizeOf(ninep.Client) <= 256); + } + + test "expired sessions do not send or consume buffered protocol work" { + var session: Session = .{ .fd = -1, .deadline = 0, .display_path = "" }; + session.cl = .init(.{ .in = &session.in, .out = &session.out }); + _ = try session.cl.submit(.{ .version = .{} }); + const queued = session.cl.output().len; + try testing.expect(queued > 0); + try testing.expectError(Error.Timeout, session.flush()); + try testing.expectEqual(queued, session.cl.output().len); + try testing.expectError(Error.Timeout, session.settle()); + try testing.expectError(Error.Timeout, session.wait(poll_in)); + } +}; diff --git a/src/9p_quic.zig b/src/9p_quic.zig new file mode 100644 index 00000000..7898adb0 --- /dev/null +++ b/src/9p_quic.zig @@ -0,0 +1,557 @@ +const std = @import("std"); +const libc = std.c; +const ssl = @import("openssl"); + +comptime { + if (ssl.OPENSSL_VERSION_NUMBER < 0x30600000) + @compileError("9P over QUIC requires OpenSSL 3.6 or newer"); +} + +pub const alpn = "pardes-9p"; +pub const Error = error{ Tls, Socket, SocketFlags, SocketOption, Bind, Address, Closed, InvalidWrite }; + +pub const Listener = struct { + fd: c_int, + handle: *ssl.SSL, + address: std.Io.net.IpAddress, + + pub fn init(address: std.Io.net.IpAddress) Error!Listener { + ssl.ERR_clear_error(); + const ctx = ssl.SSL_CTX_new(ssl.OSSL_QUIC_server_method()) orelse return error.Tls; + defer ssl.SSL_CTX_free(ctx); + const key = ssl.EVP_PKEY_Q_keygen(null, null, "EC", @as([*:0]const u8, "prime256v1")) orelse return error.Tls; + defer ssl.EVP_PKEY_free(key); + const cert = ssl.X509_new() orelse return error.Tls; + defer ssl.X509_free(cert); + if (ssl.X509_set_version(cert, 2) != 1 or + ssl.ASN1_INTEGER_set(ssl.X509_get_serialNumber(cert), 1) != 1 or + ssl.X509_gmtime_adj(ssl.X509_getm_notBefore(cert), -60) == null or + ssl.X509_gmtime_adj(ssl.X509_getm_notAfter(cert), 365 * 24 * 60 * 60) == null or + ssl.X509_set_pubkey(cert, key) != 1) return error.Tls; + const name = ssl.X509_get_subject_name(cert) orelse return error.Tls; + if (ssl.X509_NAME_add_entry_by_txt(name, "CN", ssl.MBSTRING_ASC, "pardes", -1, -1, 0) != 1 or + ssl.X509_set_issuer_name(cert, name) != 1 or + ssl.X509_sign(cert, key, ssl.EVP_sha256()) <= 0 or + ssl.SSL_CTX_use_certificate(ctx, cert) != 1 or + ssl.SSL_CTX_use_PrivateKey(ctx, key) != 1) return error.Tls; + ssl.SSL_CTX_set_verify(ctx, ssl.SSL_VERIFY_NONE, null); + ssl.SSL_CTX_set_alpn_select_cb(ctx, selectAlpn, null); + var addr: libc.sockaddr.storage = undefined; + const addr_len = sockaddr(address, &addr); + const fd = try udp(addr.family); + errdefer _ = libc.close(fd); + if (libc.bind(fd, @ptrCast(&addr), addr_len) != 0) return error.Bind; + var actual_len: libc.socklen_t = @sizeOf(@TypeOf(addr)); + if (libc.getsockname(fd, @ptrCast(&addr), &actual_len) != 0) return error.Address; + var actual = address; + actual.setPort(switch (address) { + .ip4 => std.mem.bigToNative(u16, @as(*const libc.sockaddr.in, @ptrCast(&addr)).port), + .ip6 => std.mem.bigToNative(u16, @as(*const libc.sockaddr.in6, @ptrCast(&addr)).port), + }); + const handle = ssl.SSL_new_listener(ctx, 0) orelse return error.Tls; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_fd(handle, fd) != 1 or ssl.SSL_set_blocking_mode(handle, 0) != 1 or + ssl.SSL_listen(handle) != 1) return error.Tls; + return .{ .fd = fd, .handle = handle, .address = actual }; + } + + pub fn accept(l: *Listener) Error!?Connection { + ssl.ERR_clear_error(); + const handle = ssl.SSL_accept_connection(l.handle, ssl.SSL_ACCEPT_CONNECTION_NO_BLOCK) orelse { + if (ssl.ERR_peek_error() != 0) return error.Tls; + return null; + }; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_default_stream_mode(handle, ssl.SSL_DEFAULT_STREAM_MODE_NONE) != 1 or + ssl.SSL_set_blocking_mode(handle, 0) != 1) return error.Tls; + return .{ .handle = handle }; + } + + pub fn events(l: *Listener) Error!void { + ssl.ERR_clear_error(); + if (ssl.SSL_handle_events(l.handle) != 1) return error.Tls; + } + + pub fn poll(l: *const Listener) libc.pollfd { + return pollFd(l.handle, l.fd); + } + + pub fn nextDue(l: *const Listener) ?i32 { + return due(l.handle); + } + + // Accepted connections must be released before the shared UDP socket. + pub fn deinit(l: *Listener) void { + ssl.SSL_free(l.handle); + _ = libc.close(l.fd); + l.* = undefined; + } +}; + +pub const Connection = struct { + handle: *ssl.SSL, + stream: ?*ssl.SSL = null, + fd: c_int = -1, + pending_write_len: usize = 0, + + pub fn dial(address: std.Io.net.IpAddress) Error!Connection { + ssl.ERR_clear_error(); + const ctx = ssl.SSL_CTX_new(ssl.OSSL_QUIC_client_method()) orelse return error.Tls; + defer ssl.SSL_CTX_free(ctx); + ssl.SSL_CTX_set_verify(ctx, ssl.SSL_VERIFY_NONE, null); + const fd = try udp(if (address == .ip4) libc.AF.INET else libc.AF.INET6); + errdefer _ = libc.close(fd); + const handle = ssl.SSL_new(ctx) orelse return error.Tls; + errdefer ssl.SSL_free(handle); + if (ssl.SSL_set_fd(handle, fd) != 1 or ssl.SSL_set_blocking_mode(handle, 0) != 1 or + ssl.SSL_set_default_stream_mode(handle, ssl.SSL_DEFAULT_STREAM_MODE_NONE) != 1) return error.Tls; + const protocols = [_]u8{alpn.len} ++ alpn.*; + if (ssl.SSL_set_alpn_protos(handle, &protocols, protocols.len) != 0) return error.Tls; + const peer = ssl.BIO_ADDR_new() orelse return error.Tls; + defer ssl.BIO_ADDR_free(peer); + const made = switch (address) { + .ip4 => |ip| ssl.BIO_ADDR_rawmake(peer, libc.AF.INET, &ip.bytes, ip.bytes.len, std.mem.nativeToBig(u16, ip.port)), + .ip6 => |ip| ssl.BIO_ADDR_rawmake(peer, libc.AF.INET6, &ip.bytes, ip.bytes.len, std.mem.nativeToBig(u16, ip.port)), + }; + if (made != 1 or ssl.SSL_set1_initial_peer_addr(handle, peer) != 1) return error.Tls; + return .{ .handle = handle, .fd = fd }; + } + + pub fn handshake(c: *Connection) Error!bool { + ssl.ERR_clear_error(); + var close_info: ssl.SSL_CONN_CLOSE_INFO = undefined; + if (ssl.SSL_get_conn_close_info(c.handle, &close_info, @sizeOf(@TypeOf(close_info))) == 1) + return error.Closed; + if (ssl.SSL_is_init_finished(c.handle) == 1) return true; + const rc = if (c.fd >= 0) ssl.SSL_connect(c.handle) else ssl.SSL_accept(c.handle); + if (rc == 1) return true; + try retry(c.handle, rc); + return false; + } + + fn ready(c: *Connection) Error!bool { + if (!try c.handshake()) return false; + if (c.stream != null) return true; + ssl.ERR_clear_error(); + const stream = if (c.fd >= 0) + ssl.SSL_new_stream(c.handle, ssl.SSL_STREAM_FLAG_NO_BLOCK) + else + ssl.SSL_accept_stream(c.handle, ssl.SSL_ACCEPT_STREAM_NO_BLOCK); + if (stream == null) { + if (ssl.ERR_peek_error() != 0) return error.Tls; + return false; + } + errdefer ssl.SSL_free(stream); + if (ssl.SSL_set_blocking_mode(stream, 0) != 1 or + ssl.SSL_get_stream_id(stream) != 0 or + ssl.SSL_set_incoming_stream_policy(c.handle, ssl.SSL_INCOMING_STREAM_POLICY_REJECT, 0) != 1) + return error.Tls; + _ = ssl.SSL_set_mode(stream, ssl.SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + c.stream = stream; + return true; + } + + pub fn read(c: *Connection, bytes: []u8) Error!?usize { + if (!try c.ready()) return null; + ssl.ERR_clear_error(); + var len: usize = 0; + const rc = ssl.SSL_read_ex(c.stream, bytes.ptr, bytes.len, &len); + if (rc == 1) return len; + if (ssl.SSL_get_error(c.stream, rc) == ssl.SSL_ERROR_ZERO_RETURN) return 0; + try retry(c.stream.?, rc); + return null; + } + + pub fn pending(c: *const Connection) bool { + var close_info: ssl.SSL_CONN_CLOSE_INFO = undefined; + if (ssl.SSL_get_conn_close_info(c.handle, &close_info, @sizeOf(@TypeOf(close_info))) == 1) return true; + if (c.stream) |stream| { + var item: ssl.SSL_POLL_ITEM = .{ .desc = ssl.SSL_as_poll_descriptor(stream), .events = ssl.SSL_POLL_EVENT_RE, .revents = 0 }; + const timeout: ssl.struct_timeval = .{ .tv_sec = 0, .tv_usec = 0 }; + if (ssl.SSL_poll(&item, 1, @sizeOf(@TypeOf(item)), &timeout, ssl.SSL_POLL_FLAG_NO_HANDLE_EVENTS, null) != 1) return true; + return item.revents != 0; + } + return ssl.SSL_get_accept_stream_queue_len(c.handle) != 0; + } + + pub fn write(c: *Connection, bytes: []const u8) Error!usize { + if (!try c.ready()) return 0; + if (bytes.len < c.pending_write_len) return error.InvalidWrite; + const requested = if (c.pending_write_len != 0) c.pending_write_len else bytes.len; + if (requested == 0) return 0; + ssl.ERR_clear_error(); + var len: usize = 0; + const rc = ssl.SSL_write_ex(c.stream, bytes.ptr, requested, &len); + if (rc == 1) { + c.pending_write_len = 0; + return len; + } + try retry(c.stream.?, rc); + c.pending_write_len = requested; + return 0; + } + + pub fn conclude(c: *Connection) Error!void { + if (c.pending_write_len != 0) return error.InvalidWrite; + if (!try c.ready()) return error.Closed; + ssl.ERR_clear_error(); + if (ssl.SSL_stream_conclude(c.stream, 0) != 1) return error.Tls; + } + + pub fn events(c: *Connection) Error!void { + if (c.fd < 0) return; + ssl.ERR_clear_error(); + if (ssl.SSL_handle_events(c.handle) != 1) return error.Tls; + } + + pub fn poll(c: *const Connection) ?libc.pollfd { + return if (c.fd >= 0) pollFd(c.handle, c.fd) else null; + } + + pub fn nextDue(c: *const Connection) ?i32 { + return if (c.fd >= 0) due(c.handle) else null; + } + + pub fn deinit(c: *Connection) void { + ssl.ERR_clear_error(); + _ = ssl.SSL_shutdown_ex(c.handle, ssl.SSL_SHUTDOWN_FLAG_RAPID | ssl.SSL_SHUTDOWN_FLAG_NO_STREAM_FLUSH | ssl.SSL_SHUTDOWN_FLAG_NO_BLOCK, null, 0); + ssl.SSL_free(c.stream); + ssl.SSL_free(c.handle); + if (c.fd >= 0) _ = libc.close(c.fd); + c.* = undefined; + } +}; + +fn udp(family: u16) Error!c_int { + const fd = libc.socket(family, libc.SOCK.DGRAM, 0); + if (fd < 0) return error.Socket; + errdefer _ = libc.close(fd); + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return error.SocketFlags; + var options: libc.O = @bitCast(@as(u32, @bitCast(flags))); + options.NONBLOCK = true; + if (libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(options))))) != 0 or + libc.fcntl(fd, libc.F.SETFD, @as(c_int, libc.FD_CLOEXEC)) != 0) return error.SocketFlags; + if (family == libc.AF.INET6) { + const enabled: c_int = 1; + const v6only = if (@import("builtin").os.tag.isDarwin()) 27 else libc.IPV6.V6ONLY; + if (libc.setsockopt(fd, libc.IPPROTO.IPV6, v6only, &enabled, @sizeOf(c_int)) != 0) return error.SocketOption; + } + return fd; +} + +fn sockaddr(address: std.Io.net.IpAddress, out: *libc.sockaddr.storage) libc.socklen_t { + switch (address) { + .ip4 => |ip| { + const addr: *libc.sockaddr.in = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = @bitCast(ip.bytes) }; + return @sizeOf(libc.sockaddr.in); + }, + .ip6 => |ip| { + const addr: *libc.sockaddr.in6 = @ptrCast(out); + addr.* = .{ .port = std.mem.nativeToBig(u16, ip.port), .addr = ip.bytes, .flowinfo = 0, .scope_id = 0 }; + return @sizeOf(libc.sockaddr.in6); + }, + } +} + +fn pollFd(handle: *ssl.SSL, fd: c_int) libc.pollfd { + var result: libc.pollfd = .{ .fd = fd, .events = 0, .revents = 0 }; + if (ssl.SSL_net_read_desired(handle) == 1) result.events |= libc.POLL.IN; + if (ssl.SSL_net_write_desired(handle) == 1) result.events |= libc.POLL.OUT; + return result; +} + +fn due(handle: *ssl.SSL) ?i32 { + var tv: ssl.struct_timeval = undefined; + var infinite: c_int = undefined; + if (ssl.SSL_get_event_timeout(handle, &tv, &infinite) != 1) return 0; + if (infinite != 0) return null; + const ms = @as(i128, tv.tv_sec) * 1000 + @divFloor(@as(i128, tv.tv_usec) + 999, 1000); + return @intCast(std.math.clamp(ms, 0, std.math.maxInt(i32))); +} + +fn retry(handle: *ssl.SSL, rc: c_int) Error!void { + switch (ssl.SSL_get_error(handle, rc)) { + ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE => {}, + ssl.SSL_ERROR_ZERO_RETURN => return error.Closed, + else => return error.Tls, + } +} + +fn selectAlpn(_: ?*ssl.SSL, out: [*c][*c]const u8, outlen: [*c]u8, input: [*c]const u8, len: c_uint, _: ?*anyopaque) callconv(.c) c_int { + var offset: usize = 0; + while (offset < len) { + const size = input[offset]; + offset += 1; + if (size > len - offset) return ssl.SSL_TLSEXT_ERR_ALERT_FATAL; + if (std.mem.eql(u8, input[offset..][0..size], alpn)) { + out.* = input + offset; + outlen.* = size; + return ssl.SSL_TLSEXT_ERR_OK; + } + offset += size; + } + return ssl.SSL_TLSEXT_ERR_ALERT_FATAL; +} + +const TestPair = struct { + listener: *Listener, + client: Connection, + server: ?Connection = null, + + fn init(listener: *Listener) !TestPair { + var p: TestPair = .{ .listener = listener, .client = try .dial(listener.address) }; + errdefer p.deinit(); + const deadline = testNow() + 3000; + while (true) { + try listener.events(); + try p.client.events(); + if (p.server == null) p.server = try listener.accept(); + const connected = try p.client.handshake(); + if (p.server) |*server| if (connected and try server.handshake()) return p; + try p.wait(deadline); + } + } + + fn wait(p: *TestPair, deadline: i64) !void { + const remaining = deadline - testNow(); + if (remaining <= 0) return error.Deadline; + var timeout: i32 = @intCast(@min(remaining, std.math.maxInt(i32))); + if (p.listener.nextDue()) |ms| timeout = @min(timeout, ms); + if (p.client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{ p.listener.poll(), p.client.poll().? }; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + if (testNow() >= deadline) return error.Deadline; + try p.listener.events(); + try p.client.events(); + } + + fn transfer(p: *TestPair, from_client: bool, bytes: []const u8, fragment: usize) !void { + const writer = if (from_client) &p.client else &p.server.?; + const reader = if (from_client) &p.server.? else &p.client; + var sent: usize = 0; + var received: usize = 0; + var buffer: [8192]u8 = undefined; + const deadline = testNow() + 3000; + while (received < bytes.len) { + const written = if (sent != bytes.len) try writer.write(bytes[sent..][0..@min(fragment, bytes.len - sent)]) else 0; + sent += written; + const count = try reader.read(buffer[0..@min(fragment, buffer.len)]); + if (count) |n| { + try std.testing.expect(n > 0 and n <= bytes.len - received); + try std.testing.expectEqualSlices(u8, bytes[received..][0..n], buffer[0..n]); + received += n; + } + if (testNow() >= deadline) return error.Deadline; + if (received != bytes.len and written == 0 and count == null) { + try p.wait(deadline); + } else { + try p.listener.events(); + try p.client.events(); + } + } + try std.testing.expectEqual(bytes.len, sent); + } + + fn finish(p: *TestPair) !void { + try p.client.conclude(); + try p.server.?.conclude(); + var buffer: [16]u8 = undefined; + var a = false; + var b = false; + const deadline = testNow() + 3000; + while (!a or !b) { + if (!a) if (try p.client.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + a = true; + }; + if (!b) if (try p.server.?.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + b = true; + }; + if (!a or !b) try p.wait(deadline); + } + } + + fn deinit(p: *TestPair) void { + if (p.server) |*server| server.deinit(); + p.client.deinit(); + } +}; + +fn testNow() i64 { + var ts: libc.timespec = undefined; + std.debug.assert(libc.clock_gettime(.MONOTONIC, &ts) == 0); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +test "QUIC fragmented 9P frames reconnect and stream EOF over IPv4 and IPv6" { + const request = "\x13\x00\x00\x00\x64\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const response = "\x13\x00\x00\x00\x65\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const read_request = "\x17\x00\x00\x00\x74\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00"; + const read_response = "\x16\x00\x00\x00\x75\x01\x00\x0b\x00\x00\x00" ++ "hello ninep"; + for ([_][]const u8{ "127.0.0.1", "::1" }) |host| { + var listener = try Listener.init(try .parse(host, 0)); + defer listener.deinit(); + try std.testing.expect(listener.address.getPort() != 0); + if (listener.address == .ip6) { + var enabled: c_int = 0; + var len: libc.socklen_t = @sizeOf(c_int); + const v6only = if (@import("builtin").os.tag.isDarwin()) 27 else libc.IPV6.V6ONLY; + try std.testing.expectEqual(@as(c_int, 0), libc.getsockopt(listener.fd, libc.IPPROTO.IPV6, v6only, @ptrCast(&enabled), &len)); + try std.testing.expectEqual(@as(c_int, 1), enabled); + } + for (0..3) |_| { + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(pair.server.?.poll() == null); + try std.testing.expect(pair.server.?.nextDue() == null); + for ([_]usize{ 1, 2, 7, 64 }) |fragment| { + try pair.transfer(true, request, fragment); + try pair.transfer(false, response, fragment); + try pair.transfer(true, read_request, fragment); + try pair.transfer(false, read_response, fragment); + } + try pair.finish(); + } + } +} + +test "QUIC backpressure retries a moved prefix while new replies are appended" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "hello", 1); + const bytes: [8192]u8 = @splat(0x5a); + var total: usize = 0; + const deadline = testNow() + 3000; + while (total < 64 * 1024 * 1024) { + const written = try pair.client.write(&bytes); + total += written; + if (written == 0) break; + try pair.listener.events(); + try pair.client.events(); + if (testNow() >= deadline) return error.Deadline; + } + try std.testing.expect(total > 0 and total < 64 * 1024 * 1024); + try std.testing.expectEqual(bytes.len, pair.client.pending_write_len); + try std.testing.expectError(error.InvalidWrite, pair.client.write(bytes[0..1])); + var buffer: [8192]u8 = undefined; + var received: usize = 0; + while (received < total) { + if (try pair.server.?.read(&buffer)) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(total, received); + var moved: [8192 + 7]u8 = undefined; + @memcpy(moved[0..bytes.len], &bytes); + @memset(moved[bytes.len..], 0x6b); + while (true) { + const n = try pair.client.write(&moved); + if (n != 0) { + try std.testing.expectEqual(bytes.len, n); + break; + } + try pair.wait(deadline); + } + received = 0; + while (received < bytes.len) { + if (try pair.server.?.read(&buffer)) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(bytes.len, received); + try pair.transfer(true, moved[bytes.len..], 7); + try pair.finish(); +} + +test "QUIC owner can enforce handshake and read deadlines without busy polling" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var client = try Connection.dial(listener.address); + defer client.deinit(); + const handshake_deadline = testNow() + 100; + var turns: usize = 0; + while (testNow() < handshake_deadline) { + try std.testing.expect(!try client.handshake()); + try client.events(); + var timeout: i32 = @intCast(@max(0, handshake_deadline - testNow())); + if (client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{client.poll().?}; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + turns += 1; + } + try std.testing.expect(turns < 100); + var serving = try Listener.init(try .parse("127.0.0.1", 0)); + defer serving.deinit(); + var pair = try TestPair.init(&serving); + defer pair.deinit(); + try pair.transfer(true, "request", 2); + var buffer: [32]u8 = undefined; + const read_deadline = testNow() + 100; + turns = 0; + while (true) { + try std.testing.expect(try pair.client.read(&buffer) == null); + pair.wait(read_deadline) catch |err| { + try std.testing.expectEqual(error.Deadline, err); + break; + }; + turns += 1; + } + try std.testing.expect(turns < 100); +} + +test "QUIC bind failure leaves the existing listener usable" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + for (0..8) |_| try std.testing.expectError(error.Bind, Listener.init(listener.address)); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "still listening", 3); + try pair.transfer(false, "still serving", 2); + try pair.finish(); +} + +test "QUIC pending reports buffered bytes and EOF without UDP readiness" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(!pair.server.?.pending()); + try std.testing.expectEqual(@as(usize, 3), try pair.client.write("abc")); + const deadline = testNow() + 3000; + while (!pair.server.?.pending()) try pair.wait(deadline); + var buffer: [3]u8 = undefined; + try std.testing.expectEqual(@as(?usize, 1), try pair.server.?.read(buffer[0..1])); + try std.testing.expectEqual(@as(u8, 'a'), buffer[0]); + try listener.events(); + try pair.client.events(); + try std.testing.expect(pair.server.?.pending()); + try std.testing.expectEqual(@as(?usize, 2), try pair.server.?.read(buffer[1..])); + try std.testing.expectEqualStrings("abc", &buffer); + try std.testing.expect(!pair.server.?.pending()); + try pair.client.conclude(); + while (!pair.server.?.pending()) try pair.wait(deadline); + try std.testing.expectEqual(@as(?usize, 0), try pair.server.?.read(&buffer)); +} + +test "QUIC moved listener retains its in-memory identity" { + var original = try Listener.init(try .parse("127.0.0.1", 0)); + var listener = original; + original = undefined; + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "moved listener", 2); + try pair.transfer(false, "same identity", 3); + try pair.finish(); +} diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 2df1a82e..25067a17 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -95,7 +95,7 @@ environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`, and every pty shell, `|` filter and language server inherited it — so `yazi` in `/opt/homebrew/bin` was missing in the app and present in the same build run - from a shell, which reads as "the Dock build is broken". `shell_bin` + from a shell, which reads as "the Dock build is broken". `host_io.Shell` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them and adopts the result before the first fork in all four native hosts. It appends, so an inherited entry is never demoted and a configured diff --git a/src/acmefs.zig b/src/acmefs.zig deleted file mode 100644 index dc2780fe..00000000 --- a/src/acmefs.zig +++ /dev/null @@ -1,3782 +0,0 @@ -//! ACME'S CONTROL FILESYSTEM, as a pure transaction over the core. -//! -//! plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, -//! `body`, `ctl`, `data`, `event`, `tag`..., and a program that opens those -//! files IS an editor extension — no plugin API, no embedded interpreter, no -//! rebuild. `pardes --fs` serves the same tree over Linux FUSE (src/fuse.zig), -//! and this file is the whole of what the files MEAN. Read it beside acme's -//! `fsys.c` (the tree) and `xfid.c` (the handlers). -//! -//! THE SHAPE, and why it is this shape. A filesystem is a request/response -//! protocol driven by other processes, i.e. exactly the kind of concurrency -//! the core does not have and must not grow. acme answers it with a thread per -//! in-flight request (`xfidallocthread`, a `Channel` per `Xfid`, a `QLock` per -//! window); pardes cannot and should not, so: -//! -//! * This module is a PURE MAIN-THREAD TRANSACTION: `handle(p, req) Reply`. -//! No thread, no waiting, no callback, no allocation on the hot path. It -//! is freestanding-safe (no libc, no OS) and unit-testable with no FUSE -//! anywhere near it — the tests below post requests and read replies. -//! * Requests arrive as an ordinary `Event.fs_req` and answers leave as an -//! ordinary `Effect.fs_reply`, so the transport is the queue every other -//! host<->core message already uses. A backend with no threads at all -//! (the browser, a test) is not a special case: it either never sends a -//! request, or sends one from its own frame loop. -//! * BLOCKING — acme's `event` file, whose read waits for the user to do -//! something (acme parks the `Xfid` in `w->eventx` and a later `winevent` -//! sends it a message) — is `Status.again` here: "nothing consumed, ask me -//! again". The waiting lives in the host, which is where the kernel's -//! request already is. The core keeps no waiter list and no wakeups. -//! -//! DIVERGENCE FROM ACME, deliberate: acme counts RUNES, pardes counts BYTES -//! (clamped to grapheme boundaries). Every offset in this filesystem — `addr`, -//! `data`, the event records' q0/q1, `index`'s lengths — is a byte offset, -//! because pardes is byte-addressed end to end (selections, look spots, LSP -//! offsets) and a second coordinate system would mean an O(n) conversion at -//! every boundary and a lossy `addr=dot`. acme pays that cost the other way -//! round: it keeps the document as `Rune*` and converts on every utf read -//! (`xfidutfread`, which carries a "BUG: stupid code: scan from beginning" -//! comment for its cache miss). Identical for ASCII, which is what scripts -//! compute with. -const std = @import("std"); -const mvzr = @import("mvzr"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const modal = @import("modal.zig"); -const file_pane = @import("file_pane.zig"); -const output_pane = @import("output_pane.zig"); -/// only for the scrollback read below: a terminal's `body` is a grid, and -/// term_pane owns how a grid becomes bytes (and whether there is one at all). -const term_pane = @import("term_pane.zig"); -/// only for `look.readFile`, which is what a `get` verb IS — the same -/// synchronous path-backed read `file_pane.open` does, and the one place this -/// module touches a disk. -const look = @import("look.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const MAX_PANES = pardes.MAX_PANES; - -// ============================================================================ -// THE ABI — what a transport hands in and gets back. -// ============================================================================ - -/// The filesystem operations the core answers. Protocol-neutral on purpose: -/// FUSE opcodes, 9P messages and a unit test all reduce to these. -pub const Op = enum(u8) { - /// resolve `data` (a name) inside the directory `node` - lookup, - getattr, - /// only `truncate` is honoured; a filesystem of live editor state has no - /// mode, owner or timestamps to set - setattr, - open, - read, - write, - release, - readdir, - statfs, -}; - -pub const Status = enum(u8) { - ok, - /// NO DATA YET, nothing consumed: the transport must hold this request and - /// re-submit it unchanged on a later frame. The one blocking primitive, - /// and the reason the core needs no waiters (see the header). - again, - err, -}; - -/// One operation. `data` is BORROWED for the length of the single -/// `update(.{ .fs_req = ... })` call that carries it — the same rule as -/// `.pty_read`'s bytes — so this never goes through `postEvent`. -pub const Req = struct { - /// opaque echo token; the transport's request id (FUSE `unique`) - tag: u64, - op: Op, - node: u64, - /// from `.open`, on read/write/release - handle: u32 = 0, - /// read/write: byte offset. readdir: how many entries to skip. - off: u64 = 0, - /// read/readdir: bytes wanted. Writes carry their length in `data`. - size: u32 = 0, - /// lookup: the name. write: the bytes. - data: []const u8 = &.{}, - /// setattr: a size was set (only 0 means anything here) - truncate: bool = false, -}; - -pub const Reply = struct { - tag: u64, - status: Status = .ok, - /// positive errno when `status == .err` - errno: u16 = 0, - /// lookup/getattr/setattr answer this; `open` leaves it zeroed - attr: Attr = .{}, - /// `open` answers this; every later read/write/release repeats it - handle: u32 = 0, - payload: Payload = .none, - /// write: how many of the offered bytes were taken. A short count is a - /// real answer (`data` refusing a partial grapheme), not an error. - written: u32 = 0, - - pub const Attr = struct { - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - /// permission bits only; the transport adds the format bits - mode: u16 = 0o600, - }; - - /// WHERE THE ANSWER'S BYTES ARE. Resolved by `pardes.fsPayload` inside the - /// effect drain — a borrow window identical to `.save_text`'s — so reading - /// a megabyte of body copies nothing. - pub const Payload = union(enum) { - none, - /// `State.out[0..len]`: formatted answers (ctl, addr, index, dirents, - /// event records). Valid until the next `handle` call. - staged: u32, - /// a slice of a live pane's text. `serial` rejects a reused slot - /// exactly like `.save_text` does. - region: struct { pane: u8, serial: u32, off: u32, len: u32 }, - }; - - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } -}; - -/// The errno values this filesystem returns, standing in for acme's error -/// strings (`fsys.c`/`xfid.c`: Eperm, Ebadctl, Ebadaddr, Ebadevent, Edel...). -/// A filesystem has one channel for "no": the number. -pub const E = struct { - pub const PERM: u16 = 1; - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOMEM: u16 = 12; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; - pub const NFILE: u16 = 23; - /// the one FILE here with a fixed capacity: a pane's editable tag tail is - /// a bounded one-line buffer, so a write with no room left is full rather - /// than refused (`writeTag`) - pub const NOSPC: u16 = 28; - pub const NOSYS: u16 = 38; -}; - -// ============================================================================ -// THE TREE — nodes, names, and the packing that makes both cheap. -// ============================================================================ - -/// One file inside a pane's directory: acme's `dirtabw` minus the plan9 -/// compatibility stubs (`editout` needs acme's Edit language; `draw`, -/// `consctl` and `label` are rio artefacts acme keeps for other programs' -/// sake), plus the `pty/` directory and its three files — the one thing here -/// with no prior art anywhere, because acme has no terminals and `ad` has no -/// terminal surface at all. -/// -/// A pty is a file interface wearing the wrong clothes: everything one wants -/// to do to it is an `ioctl`, and no dialect of this protocol has one. So -/// `TIOCSWINSZ` becomes `winsize 80 24`, `kill` becomes `sig INT`, spawn -/// becomes `exec`, and `TIOCGWINSZ` becomes a read of `status`. Three files -/// and no more: being first is a reason to keep it small. -/// -/// THE FIELD IS FULL AFTER THIS. `Node.file` is a u4 — sixteen values — and -/// these four take it to fifteen used. ONE VALUE (15) IS LEFT. The next file -/// added to a pane's directory needs a wider field, which means `Node`'s -/// packing changes and every node id in flight through a transport changes -/// with it; that is a deliberate wall, not an oversight, and it is why `pty/` -/// is a DIRECTORY holding three names rather than three more names beside -/// `body` — a subdirectory costs one value for the directory itself and buys -/// a namespace of its own, so `ctl` and `data` did not have to be renamed. -pub const PaneFile = enum(u4) { - dir = 0, - addr, - body, - ctl, - data, - errors, - event, - tag, - xdata, - rdsel, - wrsel, - /// the `pty/` directory itself, present only on a terminal pane - pty, - /// `pty/ctl`: `winsize`, `sig`, `exec`. Spelled with the prefix because - /// the enum is flat — the tree is two levels and the tag namespace is one - /// — and `name()` below is what puts the short name back on the wire. - pty_ctl, - /// `pty/status`: the dimensions and who holds the tty - pty_status, - /// `pty/data`: the raw stream, both directions - pty_data, - - /// Every name IS the variant's name, except the directory itself (`.` is - /// not an identifier) and the three inside `pty/`, whose names are already - /// taken by files beside `body` and so carry a prefix in the enum only. - pub fn name(f: PaneFile) []const u8 { - return switch (f) { - .dir => ".", - .pty_ctl => "ctl", - .pty_status => "status", - .pty_data => "data", - else => @tagName(f), - }; - } - - /// acme's dirtabw modes: 0400 read, 0200 write, 0600 both. - pub fn mode(f: PaneFile) u16 { - return switch (f) { - .dir, .pty => 0o500, - .errors, .wrsel, .pty_ctl => 0o200, - .rdsel, .pty_status => 0o400, - else => 0o600, - }; - } - - /// The two directories a pane has. Asked by `stat` and by every handler - /// that must refuse to treat a directory as a file. - pub fn isDir(f: PaneFile) bool { - return f == .dir or f == .pty; - } - - /// Does this name exist ONLY on a terminal pane? A file pane has no pty, - /// so the whole subtree is absent there rather than present and refusing: - /// a script tests `-d $PARDES_FS/7/pty` to find out whether pane 7 is a - /// terminal, which is a question the tree could not answer before. - pub fn inPty(f: PaneFile) bool { - return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, - else => false, - }; - } -}; - -/// The files at the root, and the root itself. `new` is a directory whose -/// every lookup CREATES a pane (acme(4): "Accessing any file in new creates a -/// new window"), which is how a script opens one without a keystroke. -pub const TopFile = enum(u4) { - root = 1, - index = 2, - cons = 3, - new = 4, - - pub fn name(f: TopFile) []const u8 { - return if (f == .root) "." else @tagName(f); - } - - pub fn mode(f: TopFile) u16 { - return switch (f) { - .root, .new => 0o500, - .index => 0o400, - .cons => 0o200, - }; - } - - pub fn dir(f: TopFile) bool { - return f == .root or f == .new; - } -}; - -/// A NODE ID, which is one integer to the kernel and two fields to us: the -/// file within a pane's directory, and the pane's SERIAL — never reused, so a -/// node id can never come to mean a different pane. acme does the same packing -/// with `QID(w->id, f)` / `WIN(q)` / `FILE(q)` macros over an int; a packed -/// struct is the same bits with the shifts and masks checked by the compiler, -/// and `serial == 0` (no pane) is what keeps the top-level ids 1..5 out of the -/// way with no separate range check. -pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, - - pub fn of(serial: u32, file: PaneFile) u64 { - std.debug.assert(serial != 0); - return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); - } - - /// What this id points at, or null when it names neither a top-level file - /// nor a possible pane file. Validity is decided HERE so no handler has to. - pub fn target(node: u64) ?Target { - const n: Node = @bitCast(node); - if (n.serial == 0) { - return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; - } - return .{ .pane = .{ - .serial = std.math.cast(u32, n.serial) orelse return null, - .file = std.enums.fromInt(PaneFile, n.file) orelse return null, - } }; - } -}; - -/// What a node id points at. -pub const Target = union(enum) { - top: TopFile, - pane: struct { serial: u32, file: PaneFile }, -}; - -// ============================================================================ -// STATE — everything the filesystem remembers between requests. -// ============================================================================ - -/// What a formatted answer starts out able to hold before it grows: `index` -/// over every pane, a directory listing, one event record. The buffer is kept -/// between requests and cleared, not freed, so the steady state allocates -/// nothing and nothing is capped by a number picked here. -pub const out_reserve = 4 * 1024; - -/// Records a reader has not taken yet, per pane. Beyond this the oldest are -/// dropped: an editor must not stall or grow without bound because a script -/// stopped reading, and a reader that fell this far behind has already lost -/// the thread — it can re-read `body` and resynchronise. (acme grows -/// `w->events` with `realloc` and has no bound at all.) -pub const queue_cap = 64 * 1024; - -/// A byte queue of formatted event records, each framed by its length so a -/// record whose TEXT contains newlines still comes out whole. -pub const Queue = struct { - buf: std.ArrayList(u8) = .empty, - /// How much of `buf` has been consumed. Popping moves this instead of - /// sliding the remainder down: a full queue holds thousands of ~20-byte - /// records, and a memmove per pop made draining one quadratic. The space - /// is reclaimed when the head passes half the buffer, so the amortised - /// cost of a pop is a pointer bump. - head: usize = 0, - - pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.deinit(gpa); - q.head = 0; - } - - pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { - if (record.len > std.math.maxInt(u32)) return; - while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { - if (q.peek() == null) return; - q.pop(); - } - q.compact(); - var head: [4]u8 = undefined; - std.mem.writeInt(u32, &head, @intCast(record.len), .little); - q.buf.appendSlice(gpa, &head) catch return; - q.buf.appendSlice(gpa, record) catch { - q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); - return; - }; - } - - /// The oldest record, or null when empty. Does not consume. - pub fn peek(q: *const Queue) ?[]const u8 { - const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; - if (rest.len < 4) return null; - const len = std.mem.readInt(u32, rest[0..4], .little); - if (rest.len < 4 + len) return null; - return rest[4 .. 4 + len]; - } - - pub fn pop(q: *Queue) void { - const record = q.peek() orelse return; - q.head += 4 + record.len; - if (q.head == q.buf.items.len) { - q.buf.clearRetainingCapacity(); - q.head = 0; - } - } - - /// Consume `n` bytes off the FRONT of the oldest record, leaving whatever - /// is left of it as the new oldest record. - /// - /// A record-framed queue can do this at all only because the frame is a - /// length written IMMEDIATELY BEFORE its bytes: shortening the record - /// means writing the new length into the four bytes that now sit just - /// before what remains, and those four bytes are inside the region the old - /// length and the consumed bytes already occupied. Nothing live is - /// overwritten and nothing moves. - /// - /// Only a STREAM wants this. `event`'s records are atomic — half a record - /// is unparseable and desynchronises the reader for the rest of the - /// session — so `event` uses `pop` and refuses a short read. `pty/data` - /// carries raw pty bytes, which have no framing of their own: the records - /// there are only "what arrived in one `.output` event" and a reader may - /// split them anywhere, exactly as `read(2)` on the pty itself would. - pub fn popFront(q: *Queue, n: usize) void { - const record = q.peek() orelse return; - if (n >= record.len) return q.pop(); - q.head += n; - std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); - } - - fn compact(q: *Queue) void { - if (q.head == 0 or q.head * 2 < q.buf.items.len) return; - const rest = q.buf.items.len - q.head; - std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); - q.buf.shrinkRetainingCapacity(rest); - q.head = 0; - } - - pub fn empty(q: *const Queue) bool { - return q.peek() == null; - } - - /// Drop everything AND give the memory back. A queue whose last reader - /// left must not hold `queue_cap` of a program's output until its pane - /// dies; `clearRetainingCapacity` inside `pop` is the right thing between - /// reads and the wrong thing between readers. - pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { - q.buf.clearAndFree(gpa); - q.head = 0; - } -}; - -/// Per-pane filesystem state, indexed by pane SLOT (not serial): it dies with -/// the pane, and a reused slot must start clean. -pub const PaneFs = struct { - /// acme's `w->addr`: where `data`/`xdata` read and write. Byte offsets. - addr: Range = .{}, - /// `limit=addr`: the range regex searches are confined to, or none. - limit: ?Range = null, - /// how many opens of this pane's `event` file are live. Non-zero means the - /// pane is SCRIPT-DRIVEN: its Look and Exec are reported, not performed. - readers: u16 = 0, - events: Queue = .{}, - /// `nomark`: writes stop pushing an undo point each, so a script's batch - /// of edits is one Undo (acme: `w->nomark`). - nomark: bool = false, - /// `noscroll`: a body write does not drag the view to the new text. - noscroll: bool = false, - /// The tag as it was at the end of the last update, so tag edits can be - /// reported without a hook in every tag mutation. Only kept while somebody - /// is listening. - tag_snap: std.ArrayList(u8) = .empty, - /// How many opens of this pane's `pty/data` file are live. THE GATE on the - /// raw queue below, and deliberately NOT `readers` above: a script reading - /// a terminal's output stream is not claiming the pane's buttons, so a pty - /// reader must not make the pane script-driven. Nobody reading means - /// `notePtyOutput` is one load and one branch and copies nothing. - pty_readers: u16 = 0, - /// Raw pty bytes on their way to the emulator, kept only while somebody is - /// reading them. The core does not buffer these anywhere else — they go - /// into the grid, and a grid cannot be un-rendered back into a byte - /// stream — so this is where `pty/data`'s read comes from. Same - /// drop-oldest cap as `events`, for the same reason: a script that stops - /// reading must not grow the editor. - pty_out: Queue = .{}, - - pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; - - fn deinit(pf: *PaneFs, gpa: std.mem.Allocator) void { - pf.events.deinit(gpa); - pf.pty_out.deinit(gpa); - pf.tag_snap.deinit(gpa); - pf.* = .{}; - } -}; - -/// The core's filesystem state. Lives on `Pardes`; zero-initialised, so a core -/// that never serves a filesystem pays one branch per frame and no memory -/// beyond this struct. -pub const State = struct { - /// Formatted answers, valid until the next `handle` call (Payload.staged). - /// Kept and cleared rather than freed: after the first few requests the - /// capacity is there and staging an answer allocates nothing. - out: std.ArrayList(u8) = .empty, - panes: [MAX_PANES]PaneFs = @splat(.{}), - /// How many `event` files are open anywhere. The one gate every recording - /// hook in the core is behind: nobody listening, nothing recorded, no diff - /// computed, no bytes copied. - listeners: u16 = 0, - /// Which input the core is handling, as acme's origin character: `K` - /// keyboard, `M` mouse, `E` a write to body/tag through this filesystem, - /// `F` an action through one of its other files. Set once per update. - origin: u8 = 'K', - - pub fn deinit(st: *State, gpa: std.mem.Allocator) void { - for (&st.panes) |*pf| pf.deinit(gpa); - st.out.deinit(gpa); - } - - /// Start a fresh answer. The previous one's bytes are dead the moment the - /// next request arrives, which is exactly the borrow window `fsPayload` - /// documents. - pub fn stage(st: *State, gpa: std.mem.Allocator) *std.ArrayList(u8) { - st.out.clearRetainingCapacity(); - st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; - return &st.out; - } - - /// A pane died: drop its filesystem state, and with it any listener count - /// it held, so a script killed with its pane cannot leave the editor - /// suppressing button actions forever. - pub fn forget(st: *State, gpa: std.mem.Allocator, id: usize) void { - if (id >= MAX_PANES) return; - st.listeners -= @min(st.listeners, st.panes[id].readers); - st.panes[id].deinit(gpa); - } - - /// Is anybody reading this pane's events? The suppression rule and every - /// recording hook ask this. - pub fn scripted(st: *const State, id: usize) bool { - return id < MAX_PANES and st.panes[id].readers != 0; - } -}; - -// ============================================================================ -// EVENT RECORDS — what the core reports, in acme's wire format. -// ============================================================================ - -/// acme's record, byte for byte: origin char, type char, then four -/// blank-separated decimals (q0, q1, flag, text length), a blank, the text, -/// and a newline — `winevent`'s `"%c%d %d %d %d %.*S\n"` with the owner char -/// pushed in front (`wind.c`). Text of 256 bytes or more is elided (the -/// reader fetches it from `data`), which is also what bounds this buffer. -pub const max_record_text = 256; - -/// The action characters. Lower case is the tag, upper case the body, which is -/// how a reader tells them apart with no extra field. -pub const Action = enum(u8) { - body_delete = 'D', - tag_delete = 'd', - body_insert = 'I', - tag_insert = 'i', - body_look = 'L', - tag_look = 'l', - body_exec = 'X', - tag_exec = 'x', - - /// The enum IS the character, the way acme's `winevent` takes a `char` and - /// prints `%c` (`wind.c`) — so these three are expressions rather than the - /// three parallel switches that spelled the same alphabet out again. - pub fn char(a: Action) u8 { - return @intFromEnum(a); - } - - pub fn fromChar(c: u8) ?Action { - return std.enums.fromInt(Action, c); - } - - /// Lower case is the tag, upper case the body: acme's whole encoding of - /// WHICH TEXT a record is about, with no extra field. - pub fn onTag(a: Action) bool { - return @intFromEnum(a) >= 'a'; - } -}; - -/// Flag bits, acme(4). Look and exec are different vocabularies at the same -/// bit positions, so they get separate names rather than one enum. -pub const flag_builtin: u32 = 1; -pub const flag_expansion: u32 = 2; -pub const flag_filename: u32 = 4; -pub const flag_chorded: u32 = 8; - -/// Format one record into `buf` and return the bytes. -pub fn formatRecord( - buf: []u8, - origin: u8, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) []const u8 { - const sent = if (text.len >= max_record_text) text[0..0] else text; - return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ - origin, - action.char(), - q0, - q1, - flag, - sent.len, - sent, - }) catch buf[0..0]; -} - -/// THE SPAN TWO VERSIONS OF A TEXT DIFFER IN: everything outside their common -/// prefix and common suffix. -/// -/// Chunked through `std.mem.eql`, which lowers to vectorised compares. That is -/// not premature: this runs on EVERY edit of a scripted pane, over the whole -/// buffer, and the byte-at-a-time loop it replaces cost 2.4x per keystroke on -/// a 40 KB body (`zig build fs-bench`, the two `keystroke` rows). -pub const Span = struct { at: u32, removed: u32, inserted: u32 }; - -pub fn diffSpan(old: []const u8, new: []const u8) Span { - const both = @min(old.len, new.len); - const stride = 64; - var head: usize = 0; - while (head + stride <= both and - std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; - while (head < both and old[head] == new[head]) head += 1; - var tail: usize = 0; - const rest = both - head; - while (tail + stride <= rest and std.mem.eql( - u8, - old[old.len - tail - stride ..][0..stride], - new[new.len - tail - stride ..][0..stride], - )) tail += stride; - while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; - return .{ - .at = @intCast(head), - .removed = @intCast(old.len - tail - head), - .inserted = @intCast(new.len - tail - head), - }; -} - -/// Report a whole-text replacement the way acme reports an edit: the deletion -/// first and then the insertion, because that is the order `textdelete` and -/// `textinsert` would have run in. pardes replaces whole buffers, so the pair -/// is recovered here — one implementation, one place that knows the order, and -/// the only cost paid by an unscripted editor is the `scripted` check. -pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { - if (!p.fs.scripted(id)) return; - const span = diffSpan(old, new); - if (span.removed == 0 and span.inserted == 0) return; - if (span.removed > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_delete else .body_delete, - span.at, - span.at + span.removed, - 0, - "", - ); - if (span.inserted > 0) _ = noteAction( - p, - id, - if (on_tag) .tag_insert else .body_insert, - span.at, - span.at + span.inserted, - 0, - new[span.at..][0..span.inserted], - ); -} - -/// Record a Look or an Exec, and say whether THE CORE MUST NOT PERFORM IT. -/// -/// That inversion is acme's whole extension model: while a script holds a -/// pane's `event` file open, buttons 2 and 3 in that pane belong to the script -/// — the words in its tag are its commands, not pardes's. A script that dies -/// closes the file and the pane goes back to being an editor. -pub fn noteAction( - p: *Pardes, - id: usize, - action: Action, - q0: u32, - q1: u32, - flag: u32, - text: []const u8, -) bool { - if (!p.fs.scripted(id)) return false; - var buf: [max_record_text + 64]u8 = undefined; - const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); - p.fs.panes[id].events.push(p.gpa, record); - return true; -} - -/// A PANE'S SHELL PRODUCED OUTPUT, raw, before the emulator ate it. -/// -/// The one hook `pty/data`'s read needs, and the reason it has to be a hook at -/// all: the core's only memory of a program's output is the emulator GRID, -/// which is a rendering — the escape sequences are gone, the scrollback is -/// reflowed, and no amount of reading it back gives a script the byte stream a -/// pipe would have given it. So the bytes are copied here, where they arrive, -/// or not at all. -/// -/// GATED ON A READER COUNT, exactly as every recording hook in this file is -/// gated on `scripted`: a pane nobody is reading pays one load and one branch -/// and allocates nothing, which is what makes an editor that serves this -/// filesystem cost the same as one that does not. `Queue` caps itself and -/// drops the oldest, so a script that opens the file and then stops reading -/// bounds the damage at `queue_cap` per pane. -pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { - if (id >= MAX_PANES or bytes.len == 0) return; - const pf = &p.fs.panes[id]; - if (pf.pty_readers == 0) return; - // SPLIT, because a record larger than `queue_cap - 4` can never be - // admitted: `Queue.push`'s eviction loop pops until `peek()` is null — - // destroying every unread byte the script was still owed — and then drops - // the new record too, silently. - // - // That is not a theoretical size. Every host reads a pty master with a - // 64 KiB buffer (`pty_chunk` in detached/server.zig, `[0x10000]u8` in - // tty.zig, gui.zig and macos.zig) and a single read really does return - // 65536 on Linux — measured. So a pane running a build or a `cat` of - // anything large produces exactly the record that empties the queue, - // repeatedly, for as long as a script holds `pty/data` open. - // - // The `event` queue never met this because its records are a few dozen - // bytes; `pty/data` inherited the cap without inheriting that property. - // Half the cap per record, so a full queue is at least two records and the - // eviction loop always has something to evict. - var off: usize = 0; - while (off < bytes.len) { - const n = @min(bytes.len - off, queue_cap / 2); - pf.pty_out.push(p.gpa, bytes[off..][0..n]); - off += n; - } -} - -// ============================================================================ -// THE TRANSACTION. -// ============================================================================ - -/// Answer one filesystem request against the live editor. The only entry -/// point: `Event.fs_req` lands here and the `Reply` leaves as -/// `Effect.fs_reply`. -pub fn handle(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); - // THE ORIGIN CHARACTER for everything this request goes on to cause — - // including the TAG DIFF the core takes at the end of the update, after - // this function has returned. acme sets `w->owner` in `winlock` and calls - // `winsettag` before `winunlock`, so the tag change a body write provokes - // (the dirty marker appearing) is attributed to that write and not to - // whatever touched the editor last. Set once, here, for the same reason. - // - // Nothing restores it: `Pardes.update` sets the origin afresh on every - // keystroke and every mouse event, which is what owns it the rest of the - // time. A read cannot cause a record, so only the mutating ops set it. - if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { - // acme's `xfidwrite` opens with exactly this: `c = 'F'; if(qid==QWtag - // || qid==QWbody) c = 'E';` — `E` is "writes to the body or tag file", - // `F` is "actions through the window's other files" (acme(4)). - .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), - .top => 'F', - }; - return switch (req.op) { - .lookup => lookup(p, req, target), - .getattr => switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }, - .setattr => setattr(p, req, target), - .open => open(p, req, target), - .release => release(p, req), - .readdir => readdir(p, req, target), - .read => read(p, req, target), - .write => write(p, req, target), - // A synthetic filesystem has no blocks. Answering successfully with - // zeros keeps `df` and anything that stats the mount working. - .statfs => .{ .tag = req.tag }, - }; -} - -const AttrResult = union(enum) { ok: Reply.Attr, missing }; - -fn attrOf(p: *Pardes, target: Target) AttrResult { - switch (target) { - .top => |f| return .{ .ok = .{ - .node = @intFromEnum(f), - .dir = f.dir(), - .mode = f.mode(), - .size = topSize(p, f), - } }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return .missing; - // THE WHOLE OF "a non-terminal pane has no pty/". Decided here so - // no handler has to: `lookup` answers with the target's attributes - // and `getattr` asks the same question, so one check makes the - // subtree ENOENT on a file pane for every operation at once. - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; - return .{ .ok = .{ - .node = Node.of(t.serial, t.file), - .dir = t.file.isDir(), - .mode = t.file.mode(), - .size = paneFileSize(p, id, t.file), - } }; - }, - } -} - -/// A size for `stat`. Exact where it is cheap and honest (`body`, `tag`), zero -/// where the file is a stream whose length is not a property (`event`, `log`); -/// FUSE serves these with direct IO, so a zero-length file still reads. -fn topSize(p: *Pardes, f: TopFile) u64 { - return switch (f) { - .root, .new, .cons => 0, - .index => indexLen(p), - }; -} - -fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { - const pane = p.panes[id] orelse return 0; - return switch (f) { - .body, .data, .xdata => bodyLen(p, pane), - .tag => tagLen(p, pane), - // `pty/status` is formatted per read like `ctl` is, and `pty/data` is - // a stream whose length is not a property of anything. - .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, - .pty, .pty_ctl, .pty_status, .pty_data => 0, - }; -} - -// --------------------------------------------------------------------------- -// PER-FILE SEMANTICS. Everything above is the frame: the ABI, the tree, the -// state, the records. Everything below is what acme's xfid.c does. -// --------------------------------------------------------------------------- - -// =========================================================================== -// THE TWO TEXTS A PANE HAS. Every handler below asks these, so "what is this -// pane's body" has one answer here and not eleven answers scattered about. -// =========================================================================== - -/// A pane's BODY, BORROWED. A file pane — which includes every output buffer -/// — lends its content, and that is the whole reason a `body` read costs -/// nothing (`Payload.region`). A terminal has no such buffer: its body is the -/// emulator's scrollback, which has to be RENDERED before it is bytes, so it -/// is not lendable and `readBody` produces one instead. Empty here therefore -/// means "nothing to lend", which for a terminal is not "empty document". -fn bodyOf(pane: *const Pane) []const u8 { - if (pane.file) |*f| return f.content; - return ""; -} - -/// ...and the writable side of the same question. Null is "this pane has no -/// document", which is every terminal and the answer to every write that -/// would need one. -fn fileOf(pane: *Pane) ?*file_pane.State { - return if (pane.file) |*f| f else null; -} - -/// The pane's TAG exactly as it is drawn: the live read-only prefix (the path, -/// the dirty marker, the pane's builtin words, the alignment gap) then the -/// editable tail. -/// -/// Scratch-owned — and `Pardes.update` resets that arena before the transport -/// ever reads a payload, so every tag answer is COPIED into `State.out`. -/// `body` is the only text lent out, because it is the only one that is a -/// buffer rather than a rendering. -fn tagOf(p: *Pardes, pane: *Pane) []const u8 { - return p.tagText(p.scratch.allocator(), pane) catch ""; -} - -/// The directory a pane belongs to: acme's "the directory currently named in -/// the tag", which is where this pane's `+Errors` goes. -fn dirOf(pane: *Pane) []const u8 { - if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/"; - const cwd = pane.cwdSlice(); - return if (cwd.len > 0) cwd else "/"; -} - -/// acme's `w->dirty`: the body differs from what is on disk. A terminal and an -/// output buffer have nothing on disk, so they are never dirty — the same -/// `saves` trait the tag's `*` marker already asks. -fn dirtyOf(pane: *const Pane) bool { - const f = if (pane.file) |*x| x else return false; - if (!output_pane.fileTraits(f.output).saves) return false; - return f.revision != f.saved_revision; -} - -/// A byte offset as a `Range` field. A pane holding four gigabytes of text is -/// not something this editor does; saturating is honest where a silent wrap -/// would hand a script an address pointing at the wrong end of the file. -fn clip(n: usize) u32 { - return std.math.cast(u32, n) orelse std.math.maxInt(u32); -} - -fn cellOf(row: i32, col: i32) modal.Cursor { - return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) }; -} - -fn firstLine(s: []const u8) []const u8 { - return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; -} - -/// acme's DOT — the user's selection — as a byte range over the body. -/// -/// pardes keeps the selection as two (row, col) cells with a HELIX block -/// cursor, i.e. the head cell is INSIDE the range; acme's dot is gap to gap. -/// This is the one place that conversion lives and `setDot` is its inverse, so -/// `addr=dot` followed by `dot=addr` is the identity rather than a range that -/// creeps by one grapheme each round trip. -fn dotOf(pane: *Pane) PaneFs.Range { - const text = bodyOf(pane); - const head = modal.hxOff(text, cellOf(pane.cur_row, pane.cur_col)); - if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.hxOff(text, cellOf(pane.vsel.row, pane.vsel.col)); - var hi = @max(head, anchor); - if (hi < text.len) hi = modal.nextGrapheme(text, hi); - return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; -} - -/// acme's `textsetselect`. The head lands ON the last grapheme of the range, -/// never one past it, because that is where every pardes motion leaves it and -/// a cursor sitting one cell right of its own selection is a selection the -/// acme chords will not act on. -fn setDot(pane: *Pane, r: PaneFs.Range) void { - const text = bodyOf(pane); - const q0 = @min(@as(usize, r.q0), text.len); - const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.hxPos(text, q0); - pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.hxPos(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); - pane.cur_row = @intCast(h.row); - pane.cur_col = @intCast(h.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.msel.active = false; - pane.ensureCursorVisible(); -} - -/// acme's `textshow`: put a spot on screen. Suppressed by `noscroll`. -fn showOffset(pane: *Pane, off: usize) void { - const text = bodyOf(pane); - const c = modal.hxPos(text, @min(off, text.len)); - pane.cur_row = @intCast(c.row); - pane.cur_col = @intCast(c.col); - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); -} - -/// acme's `clampaddr`. Its `Range` is signed and it clamps both ends; ours is -/// unsigned, so only the top can be wrong — and it can, the moment a pane's -/// body shrinks under a stored address. -fn clampAddr(pf: *PaneFs, len: usize) void { - const n = clip(len); - pf.addr.q0 = @min(pf.addr.q0, n); - pf.addr.q1 = @min(pf.addr.q1, n); - if (pf.limit) |*l| { - l.q0 = @min(l.q0, n); - l.q1 = @min(l.q1, n); - } -} - -/// Move a range across an edit at `at` that replaced `removed` bytes with -/// `inserted` — acme's `if(tq0 >= q0) tq0 += nr;`, applied to both ends, which -/// is what keeps a script rewriting text under your cursor from dragging the -/// cursor onto a different word. -fn shiftBy(r: PaneFs.Range, at: u32, removed: u32, inserted: u32) PaneFs.Range { - return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) }; -} - -fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { - if (v <= at) return v; - if (v <= at +| removed) return at +| inserted; - return v - removed +| inserted; -} - -/// How many of these bytes end on a character boundary. -/// -/// acme buffers a partial rune on the Fid (`fullrunewrite` plus `f->rpart`) -/// and stitches it onto the next write. A SHORT COUNT is the POSIX spelling of -/// the same promise — the writer's libc retries with the tail — and it needs -/// no per-handle state at all. Never zero for a -/// non-empty write: a writer handed 0 retries the same bytes forever. -fn wholeUtf8(data: []const u8) usize { - var i = data.len; - var back: usize = 0; - while (i > 0 and back < 4) : (back += 1) { - i -= 1; - const c = data[i]; - if (c < 0x80) return data.len; // an ASCII tail is always complete - if (c & 0xC0 == 0xC0) { // a lead byte: is its sequence all here? - const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; - if (i + need <= data.len or i == 0) return data.len; - return i; - } - } - // four trailing continuation bytes and no lead: not UTF-8 at all. acme's - // `cvttorunes` substitutes for bad bytes rather than refusing them, and so - // does storing them verbatim. - return data.len; -} - -// =========================================================================== -// LOOKUP — acme's `fsyswalk`, minus 9P's fid bookkeeping. -// =========================================================================== - -/// A name inside a pane's directory. `.` and `..` are the kernel's business, -/// never ours, the directory variant is not nameable, and the three inside -/// `pty/` are not nameable HERE — their enum names carry a prefix precisely so -/// that `stringToEnum` cannot hand `7/pty_ctl` back as a file beside `body`. -/// `pty` itself resolves; whether it EXISTS is `attrOf`'s question. -fn paneFileNamed(name: []const u8) ?PaneFile { - const f = std.meta.stringToEnum(PaneFile, name) orelse return null; - if (f == .dir) return null; - return if (f.inPty() and f != .pty) null else f; -} - -/// ...and a name inside `pty/`, which is a separate namespace: `ctl` and -/// `data` mean different files on the two sides of the slash, which is the -/// whole reason `pty/` is a directory (see `PaneFile`). -fn ptyFileNamed(name: []const u8) ?PaneFile { - if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; - if (std.mem.eql(u8, name, "status")) return .pty_status; - if (std.mem.eql(u8, name, "data")) return .pty_data; - return null; -} - -fn topFileNamed(name: []const u8) ?TopFile { - const f = std.meta.stringToEnum(TopFile, name) orelse return null; - return if (f == .root) null else f; -} - -/// acme: "is it a numeric name? yes: it's a directory". A pane's directory is -/// named by its SERIAL, which is never reused, so a stale path can go stale -/// but can never come to mean a different pane. -fn serialNamed(name: []const u8) ?u32 { - if (name.len == 0 or name.len > 10) return null; - for (name) |c| if (c < '0' or c > '9') return null; - return std.fmt.parseInt(u32, name, 10) catch null; -} - -/// The smallest live serial greater than `after`, so a caller can walk every -/// pane in ascending serial without sorting anything. O(panes) per step over -/// at most sixteen slots, and no allocation — the alternative was a scratch -/// array in a function that must not allocate. -fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { - var best: ?u32 = null; - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.serial <= after) continue; - if (best == null or pane.serial < best.?) best = pane.serial; - } - return best; -} - -/// Create a pane the way the `New` builtin does — an empty scratch below the -/// active one, in its column — and answer its serial. -/// -/// acme has `newwindowthread` sitting on a channel for exactly this, and its -/// windows go wherever `rowadd` puts them. Going through `newScratchBelow` -/// means a pane a script opened is in every respect a pane you opened: same -/// tag, same builtins, same undo, same Del. -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; - p.newScratchBelow(p.active); - const pane = p.panes[slot] orelse return null; - return pane.serial; -} - -fn lookup(p: *Pardes, req: Req, target: Target) Reply { - const name = req.data; - if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); - const node: u64 = switch (target) { - .top => |f| switch (f) { - .root => root: { - if (topFileNamed(name)) |t| break :root @intFromEnum(t); - const serial = serialNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - _ = p.paneBySerial(serial) orelse return Reply.fail(req.tag, E.NOENT); - break :root Node.of(serial, .dir); - }, - .new => new: { - // acme(4): "Accessing any file in new creates a new window." - // - // acme creates it one component EARLIER — `fsyswalk` sends on - // `cnewwindow` the moment it walks the name `new` itself. That - // cannot work over FUSE: the kernel CACHES the dentry for - // `new`, so a lookup there would fire once per mount and never - // again. Creating at the CHILD keeps the promise the man page - // makes (`echo hi > $PARDES_FS/new/body` opens a pane holding - // `hi`) under a protocol that caches. - // - // The name is checked BEFORE the pane is made, so a stat of - // `new/nosuchfile` leaves no litter. acme's walk creates the - // window first and then fails the second component, which - // leaves an empty window behind for every typo. - const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); - // `new/` makes a SCRATCH pane, which is a document and never a - // terminal, so `new/pty` names something that cannot exist. - // Refused before the pane is made, for the same reason every - // other bad name here is: a typo must leave no litter. - if (want.inPty()) return Reply.fail(req.tag, E.NOENT); - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - break :new Node.of(serial, want); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| pane: { - _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - // Two directories, two namespaces. `attrOf` below is what decides - // whether the `pty` half exists on this pane at all. - const f = switch (t.file) { - .dir => paneFileNamed(name), - .pty => ptyFileNamed(name), - else => return Reply.fail(req.tag, E.NOTDIR), - } orelse return Reply.fail(req.tag, E.NOENT); - break :pane Node.of(t.serial, f); - }, - }; - // A lookup answers with the TARGET's attributes, which is exactly what a - // getattr of that node would say — one spelling, so the two can never - // disagree about a size or a mode. - return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READDIR -// =========================================================================== - -/// One directory entry in the transport-neutral staging format `src/fuse.zig` -/// decodes: node id, kind, name length, name — packed, little-endian, no -/// padding. A readdir answer is that record repeated. -/// -/// `node` travels because it becomes the `d_ino` a `getdents64` reports, and a -/// `d_ino` that disagrees with the later `st_ino` is a filesystem that lies to -/// `find -inum`. -fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { - if (name.len == 0 or name.len > 255) return; - var head: [10]u8 = undefined; - std.mem.writeInt(u64, head[0..8], node, .little); - head[8] = @intFromBool(dir); - head[9] = @intCast(name.len); - out.appendSlice(gpa, &head) catch return; - out.appendSlice(gpa, name) catch return; -} - -/// The pane files, for a pane directory. `pty/` is listed only on a terminal: -/// a file pane's listing is byte for byte what it was before that directory -/// existed, which is what keeps every existing script's `ls` unsurprised. -fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - // The directory itself is never an entry, and the three names inside - // `pty/` belong to THAT directory's listing rather than to this one — - // the enum is flat, the tree is not. - if (comptime f == .dir or (f.inPty() and f != .pty)) continue; - // `pty/` itself is present only on a terminal. A runtime `continue` - // cannot leave an `inline for` body, so the entry is conditional - // rather than the iteration. - const present = f != .pty or terminal; - if (present) { - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); - } - } -} - -/// ...and the three inside `pty/`, in declaration order like every other -/// listing here, so a script that walks the tree twice can diff the walks. -fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { - inline for (comptime std.enums.values(PaneFile)) |f| { - if (comptime !f.inPty() or f == .pty) continue; - if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); - } -} - -fn readdir(p: *Pardes, req: Req, target: Target) Reply { - const out = p.fs.stage(p.gpa); - var skip = req.off; - switch (target) { - .top => |f| switch (f) { - .root => { - inline for (.{ TopFile.index, TopFile.cons, TopFile.new }) |t| { - if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); - } - // Ascending serial: serials are never reused, so this order is - // stable across a create and a delete — which is what a script - // that walks the tree twice and diffs the two walks needs. - // acme lists windows in SCREEN order (column by column), which - // changes when you drag a window and says nothing a script can - // rely on. - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - if (skip > 0) { - skip -= 1; - continue; - } - var buf: [16]u8 = undefined; - const name = std.fmt.bufPrint(&buf, "{d}", .{s}) catch continue; - stageDirent(out, p.gpa, Node.of(s, .dir), true, name); - } - }, - // `new/` ENUMERATES NOTHING, and that is a guarantee rather than a - // shrug: the names it could list are exactly the names whose LOOKUP - // creates a pane, and every tool that lists a directory then stats - // what it found — `ls -l`, `ls --color`, `find`, a shell completing - // `$PARDES_FS/new/` — would make one pane per name. acme never - // lists it either. Naming a file here is what creates one; see - // `lookup`. - .new => {}, - else => return Reply.fail(req.tag, E.NOTDIR), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const terminal = p.panes[id].?.isTerminal(); - switch (t.file) { - .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), - // A node id naming `pty/` can only have come from a pane that - // was a terminal when it was resolved. It may not be one now - // (a pane can acquire a document), so answer what a lookup - // would answer today rather than trusting the id. - .pty => { - if (!terminal) return Reply.fail(req.tag, E.NOENT); - stagePtyFiles(p, out, t.serial, &skip); - }, - else => return Reply.fail(req.tag, E.NOTDIR), - } - }, - } - // Zero bytes is END OF DIRECTORY, never an error: the transport stops - // asking, and re-staging from scratch on every call is what makes a - // partially consumed answer safe to ask for again at a higher cookie. - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// OPEN / RELEASE / SETATTR -// =========================================================================== - -/// Open carries no per-open state, because there is none to carry: `addr` and -/// `limit` belong to the pane (as they do in acme, where they are Window -/// fields), and every read brings its own offset. What an open DOES do is -/// arm the two things acme arms on open, and count the two kinds of reader. -/// -/// So there is no fid table. acme needs one because 9P walks to a fid and -/// every later message names only that fid; FUSE puts the nodeid on every -/// request, RELEASE included, so the handle is decoration. It is answered -/// non-zero only because the transport spells "no handle" as zero. -fn open(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => {}, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pf = &p.fs.panes[id]; - // A file that is not there cannot be opened, so the reader count - // below cannot be armed on a pane with no pty. Same answer - // `lookup`, `read` and `write` give (`attrOf`). - if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); - switch (t.file) { - // acme(4): "When the ctl file is first opened, regular - // expression context searches in addr addresses examine the - // whole file"; `limit=addr` narrows them again. - .ctl => pf.limit = null, - // acme resets both on the FIRST open (`w->nopen[QWaddr]++ == - // 0`) and keeps a per-file open count to know. There is none - // here: `addr` is one piece of per-pane state that a second - // opener would be sharing anyway, so the honest reading of - // "first" is "whenever somebody opens it" — and a script's - // first act on `addr` is always to write one. - .addr => { - pf.addr = .{}; - pf.limit = null; - }, - // THE SUPPRESSION GATE. While this is non-zero the pane is - // script-driven: its Look and Exec are reported, not - // performed (`noteAction`). Counted per OPEN, not per pane, so - // two readers means the second one closing leaves the first - // still in charge. - .event => { - pf.readers +|= 1; - p.fs.listeners +|= 1; - }, - // THE OTHER GATE, and deliberately a separate count: while - // this is non-zero the raw pty bytes are copied into - // `pty_out` as they arrive (`notePtyOutput`). It does NOT - // touch `listeners` — reading a terminal's output stream is - // not claiming the pane's buttons, and a script that did both - // would have opened `event` too. - .pty_data => pf.pty_readers +|= 1, - else => {}, - } - }, - } - return .{ .tag = req.tag, .handle = 1 }; -} - -fn release(p: *Pardes, req: Req) Reply { - const target = Node.target(req.node) orelse return .{ .tag = req.tag }; - switch (target) { - .top => {}, - .pane => |t| { - if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; - // The pane may have DIED while this was open. `State.forget` has - // then already taken its whole reader count out of `listeners` - // (the core calls it from `deinitPane`), so a serial that no - // longer resolves must not be decremented a second time — that - // underflow is exactly what would leave the editor suppressing - // button actions forever with no script left to interpret them. - const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; - const pf = &p.fs.panes[id]; - if (t.file == .pty_data) { - if (pf.pty_readers == 0) return .{ .tag = req.tag }; - pf.pty_readers -= 1; - // The LAST pty reader leaving takes the queue's MEMORY with - // it, not merely its contents: `queue_cap` per pane held - // until the pane dies would be an editor that grew by being - // scripted once. And what is in it is stale anyway — the next - // reader wants the program's output from when IT opened the - // file, not a replay of somebody else's session. - if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); - return .{ .tag = req.tag }; - } - if (pf.readers == 0) return .{ .tag = req.tag }; - pf.readers -= 1; - p.fs.listeners -|= 1; - // The LAST reader leaving takes the tag snapshot with it. It is - // only ever compared against while somebody is listening, so - // keeping it would let the tag drift unobserved and then hand the - // NEXT reader a `d`/`i` pair for a change it never saw. - if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); - }, - } - return .{ .tag = req.tag }; -} - -fn setattr(p: *Pardes, req: Req, target: Target) Reply { - // acme has NO equivalent: 9P has no truncate-on-open, so nothing in - // `xfid.c` answers a Twstat carrying a length. Linux does — `> body` is - // O_TRUNC — and refusing it would make the shell's most natural way to - // REPLACE a pane's text (rather than append to it) fail with EPERM on the - // redirect, before a single byte was written. So exactly one field is - // honoured, only the value zero means anything, and everything else a - // `stat` structure can carry (mode, owner, times) is silently accepted and - // ignored the way a filesystem of live editor state has to. - if (req.truncate) switch (target) { - .pane => |t| switch (t.file) { - .body, .data, .xdata => { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (fileOf(pane) != null) { - _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse - return Reply.fail(req.tag, E.NOMEM); - p.fs.panes[id].addr = .{}; - setDot(pane, .{}); - } - }, - else => {}, - }, - else => {}, - }; - return switch (attrOf(p, target)) { - .ok => |a| .{ .tag = req.tag, .attr = a }, - .missing => Reply.fail(req.tag, E.NOENT), - }; -} - -// =========================================================================== -// READ -// =========================================================================== - -/// Answer with a WINDOW onto what was just staged. `Payload.staged` is a -/// LENGTH from the start of the buffer, so a read at an offset slides the -/// bytes down rather than growing the payload union with a second field -/// nothing else would ever use. -fn staged(p: *Pardes, req: Req) Reply { - const out = &p.fs.out; - const off = @min(req.off, out.items.len); - const n = @min(out.items.len - off, req.size); - if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); - out.shrinkRetainingCapacity(n); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; -} - -fn read(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - .index => readIndex(p, req), - // acme's dirtab: `cons` is 0200 and a directory is not read(2)able. - .cons, .root, .new => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - const pf = &p.fs.panes[id]; - // A `pty/` node whose pane is no longer a terminal reads as - // absent, not as empty: the same answer `lookup` gives today. - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => readAddr(p, req, pf, pane), - .body => readBody(p, req, id, pane), - .ctl => readCtl(p, req, pane), - .data => readData(req, id, pane, pf, false), - .xdata => readData(req, id, pane, pf, true), - .tag => readTag(p, req, pane), - .event => readQueue(p, req, &pf.events), - .rdsel => readRdsel(req, id, pane), - .pty_status => readPtyStatus(p, req, id, pane), - .pty_data => readPtyData(p, req, pf), - .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// acme's `Ctlsize`: five `%11d ` fields = 60 bytes, before the tag. -const ctl_fields = 5 * 12; - -/// acme's `winctlprint(w, buf, 0)` — the five numbers `index` and `ctl` share. -/// -/// COST: acme reads the tag's length off `w->tag.file->nc` for free, because -/// acme's tag IS a buffer. pardes's is COMPUTED every time it is asked for -/// (path, dirty marker, builtins, and the alignment gap, which is measured -/// against every other pane in the same layout column), so these five numbers -/// cost one tag render — a couple of microseconds and a few bumps of the -/// per-update scratch arena, which `Pardes.update` resets. That is the price -/// of the second field being the number a `tag` read will actually hand back; -/// a cheaper approximation that disagreed with `read tag` would be worse than -/// slow, it would be wrong. -fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ - pane.serial, - tagOf(p, pane).len, - bodyOf(pane).len, - // acme's `isdir` marks a window holding a DIRECTORY LISTING. pardes - // never opens one — a Look at a directory spawns a shell there - // (look.zig) — so this is structurally zero, not unimplemented. - @as(u32, 0), - @intFromBool(dirtyOf(pane)), - }) catch {}; -} - -/// acme's `xfidindexread`: one line per pane, the five numbers then the tag up -/// to its first newline. Seekable, so a script can pread the middle of it — -/// "at character position 5×12 starts the name of the window" (acme(4)). -fn readIndex(p: *Pardes, req: Req) Reply { - const out = p.fs.stage(p.gpa); - var last: u32 = 0; - while (nextSerialAfter(p, last)) |s| { - last = s; - const pane = p.panes[p.paneBySerial(s).?].?; - stageCtlNumbers(p, out, pane); - out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; - out.append(p.gpa, '\n') catch {}; - } - return staged(p, req); -} - -/// acme: `sprint(buf, "%11d %11d ", w->addr.q0, w->addr.q1)`. acme's numbers -/// are RUNE offsets; these are bytes (see the header). "Thus a regular -/// expression may be evaluated by writing it to addr and reading it back." -fn readAddr(p: *Pardes, req: Req, pf: *PaneFs, pane: *Pane) Reply { - clampAddr(pf, bodyOf(pane).len); - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; - return staged(p, req); -} - -fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (pane.file != null) { - // ZERO COPY: `.region` is resolved by `fsPayload` during the effect - // drain, so reading a megabyte of body moves no bytes in here at all. - // This is the whole reason `Payload` is a union and not a slice. - const text = bodyOf(pane); - const off = @min(req.off, text.len); - const n = @min(text.len - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(off), - .len = clip(n), - } } }; - } - // A TERMINAL has no such buffer. acme's body is always a `Text`; pardes's - // is a terminal emulator, and its "body" is the scrollback — which only - // becomes bytes when somebody renders the pages into lines. So it is - // produced, staged, and paid for per read. `win`'s transcript, read side. - const text = term_pane.screenTextAlloc(pane, p.gpa) catch - return Reply.fail(req.tag, E.NOMEM); - defer p.gpa.free(text); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); - return staged(p, req); -} - -/// The face the shell was last asked to wear. acme owns its fonts and prints -/// the real one; the core only knows what it REQUESTED — on a tty the font -/// belongs to the terminal emulator and in the browser to the page — so it -/// prints that, or `default`, which is the same word the Debug overlay shows -/// for the same reason. -fn fontName(p: *Pardes) []const u8 { - const name = p.settings.font.effective_name.get(); - return if (name.len == 0) "default" else name; -} - -/// plan9's `%q` (`quotestrfmt`): a string with nothing special in it prints -/// bare, anything else is wrapped in single quotes with internal quotes -/// doubled. Load-bearing rather than decoration — a script splits the ctl line -/// into shell words, and a font name with a space in it is one word. -fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { - const plain = s.len > 0 and for (s) |c| { - if (c <= ' ' or c == '\'') break false; - } else true; - if (plain) { - out.appendSlice(gpa, s) catch {}; - return; - } - out.append(gpa, '\'') catch {}; - for (s) |c| { - if (c == '\'') out.append(gpa, '\'') catch {}; - out.append(gpa, c) catch {}; - } - out.append(gpa, '\'') catch {}; -} - -/// acme's `winctlprint(w, buf, 1)`: index's five numbers plus three more. -fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - stageCtlNumbers(p, out, pane); - // acme prints `Dx(w->body.r)` — the body's width in PIXELS — and - // `w->body.maxtab`, a tab's width in pixels too. pardes is a CELL GRID: - // on a tty there is no pixel width to report at all, and on the two pixel - // shells the number a script actually wants is still how many characters - // fit. So both are CELLS. A script that would have divided by the font - // width to get columns gets columns without dividing. - out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; - stageQuoted(out, p.gpa, fontName(p)); - out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; - return staged(p, req); -} - -fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; - return staged(p, req); -} - -/// acme's `xfidruneread`: hand back whole characters from the START of `addr` -/// and move `addr` to the null string just after them; `xdata` additionally -/// stops at the END of `addr` (acme passes `w->addr.q1` where `data` passes -/// `nc`). The file offset is ignored — `addr` is the position. -/// -/// "Whole characters" is acme's partial-rune rule; here it is a GRAPHEME -/// boundary, which is strictly stronger and is what every other offset in -/// pardes already respects. A read too small for the next grapheme returns -/// zero bytes rather than half of one — acme's `if(m == 0) break`. -fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneFs, stop_at_end: bool) Reply { - const text = bodyOf(pane); - clampAddr(pf, text.len); - const q0: usize = pf.addr.q0; - // acme carries a "BUG: what should happen if q1 > q0?" here and answers by - // reading nothing. An inverted address is a legal thing to have written - // (`address()` never normalises), so the empty read is the answer. - const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; - var end = @min(hi, q0 +| req.size); - end = @max(q0, modal.graphemeStart(text, end)); - // `data` collapses the address onto the point it read up to; `xdata` moves - // only q0 and KEEPS q1, because q1 is the stop address the man page - // promises ("reads stop at the end address") and the next chunked read has - // to be able to continue from where this one stopped. acme spells the same - // difference at xfid.c:331-341: QWdata assigns both, QWxdata only q0. - pf.addr.q0 = clip(end); - if (!stop_at_end) pf.addr.q1 = clip(end); - if (pane.file == null) return .{ .tag = req.tag }; - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(q0), - .len = clip(end - q0), - } } }; -} - -/// acme copies the selection into a TEMP FILE at open, with a comment -/// apologising for it, so a `|sort` cannot see the text change underneath. -/// There is no such window here: the whole request is one main-thread -/// transaction, nothing can run between the open and the read, and the bytes -/// go out of the pane unmoved. -fn readRdsel(req: Req, id: usize, pane: *Pane) Reply { - if (pane.file == null) return .{ .tag = req.tag }; - const text = bodyOf(pane); - const d = dotOf(pane); - const lo = @min(@as(usize, d.q0), text.len); - const hi = @max(lo, @min(@as(usize, d.q1), text.len)); - const off = @min(req.off, hi - lo); - const n = @min(hi - lo - off, req.size); - return .{ .tag = req.tag, .payload = .{ .region = .{ - .pane = @intCast(id), - .serial = pane.serial, - .off = clip(lo + off), - .len = clip(n), - } } }; -} - -/// ONE RECORD PER READ, and `Status.again` when there is none. -/// -/// This is the whole of what acme's blocking `event` read becomes. acme parks -/// the `Xfid` in `w->eventx` and `winevent` sends it a message to wake it up; -/// the waiting lives in a thread per in-flight request, and `xfidflush` exists -/// to cancel one. Here nothing is consumed and nothing is remembered: the -/// transport still holds the kernel's request and asks again. No waiter list, -/// no wakeup, no flush bookkeeping, and no loop anywhere in the core. -fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { - const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - // acme hands back as much of its event buffer as the count allows and - // keeps the rest, which can split a record down the middle; a reader is - // simply expected never to ask for less than one. Refusing is the honest - // version of that contract — half a record is unparseable and silently - // desynchronises the reader for the rest of the session. - if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); - const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); - q.pop(); - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// `pty/` — the terminal a pane is, as three files. No prior art: acme has no -// terminals and `ad` has no terminal surface at all, so nobody has made these -// mistakes for us and nobody's scripts expect a particular spelling. Which is -// the argument for three files and no fourth. -// =========================================================================== - -/// `pty/status`: `%11d `-formatted, exactly like `ctl` and `index`, so a -/// script splits it the same way and `read`s it at an offset. -/// -/// THREE NUMBERS, and the choice of which three is the whole content of this -/// function. The core knows the grid it asked for and it can ask the host who -/// holds the tty; that is all it knows, and inventing a fourth field would be -/// inventing the number behind it. -/// -/// cols, rows the grid, in cells. What `TIOCGWINSZ` would answer, and the -/// same pair `winsize` sets — so a script can set a size and -/// read back that it took. -/// taken 1 while a PROGRAM holds the tty (vim, a pager, a build), 0 -/// at the shell's own prompt. `pull_tty_taken`, the probe the -/// core already asks before it types a command line; a host -/// that cannot tell says 0, which is how pardes behaved before -/// the probe existed. -/// -/// WHAT IS NOT HERE, and why not, because a missing field is a fact about the -/// core rather than an omission: -/// -/// exit status NOT TRACKED ANYWHERE. A shell's death arrives as -/// `Event.eof`, whose whole handler is `removePane` — the pane -/// and its serial are gone, so by the time anybody could read -/// a status file there is no directory to read it in. Reporting -/// a zero here would be reporting a number the core does not -/// have. Giving the pane an exit status means keeping the pane -/// alive past its child, which is a change to what a terminal -/// pane IS and does not belong in a status file's formatter. -/// raw/cooked the draft's `TCSETS` line. The core never sets a termios: -/// the mode belongs to the program on the far side of the pty, -/// which sets it for itself and never tells us. There is -/// nothing to report and nothing to set. -fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ - pane.cols, - pane.rows, - @intFromBool(p.hostTtyTaken(id)), - }) catch {}; - return staged(p, req); -} - -/// `pty/data`, read side: THE RAW OUTPUT STREAM, as a stream. -/// -/// FRAMING, which is the one decision here. `event` refuses a read smaller -/// than one record because half a record is unparseable. Raw pty bytes have no -/// records: what is in the queue is only "what arrived in one `.output` -/// event", which is wherever the host's `read(2)` happened to land, so -/// refusing a short read would be enforcing a boundary that means nothing — -/// and a reader with a 1 KB buffer would deadlock against a 4 KB arrival -/// forever. So this hands back as much as the count allows, spanning arrivals, -/// and keeps the remainder (`Queue.popFront`). That is what `read(2)` on the -/// pty itself would do. -/// -/// The OFFSET is ignored, for the same reason `event`'s is: the queue is the -/// position. And an empty queue is `Status.again` — nothing consumed, ask me -/// again — which is the whole of how a blocking read works here. -/// -/// A pane nobody has OPENED this file on has an empty queue by construction -/// (`notePtyOutput` is gated on the count `open` keeps), so a read that beats -/// the first byte of output and a read on a pane that never recorded any are -/// the same cheap answer. -fn readPtyData(p: *Pardes, req: Req, pf: *PaneFs) Reply { - if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; - const out = p.fs.stage(p.gpa); - while (out.items.len < req.size) { - const chunk = pf.pty_out.peek() orelse break; - const n = @min(chunk.len, req.size - out.items.len); - out.appendSlice(p.gpa, chunk[0..n]) catch break; - pf.pty_out.popFront(n); - } - return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; -} - -// =========================================================================== -// WRITE -// =========================================================================== - -fn write(p: *Pardes, req: Req, target: Target) Reply { - switch (target) { - .top => |f| return switch (f) { - // acme(4): text written to `cons` appears in `dir/+Errors`, where - // `dir` is the directory the command ran in — acme knows which - // from the mount the writer inherited (`x->f->mntdir`, one per - // `win`). A FUSE mount is ONE directory for the whole editor, so - // the writing process is anonymous and the only defensible owner - // is the pane the user is in. A script that wants a specific - // pane's errors writes `/errors`, which is unambiguous. - .cons => if (appendErrors(p, p.active, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - else => Reply.fail(req.tag, E.PERM), - }, - .pane => |t| { - const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); - const pane = p.panes[id].?; - if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); - return switch (t.file) { - .addr => writeAddr(p, req, id, pane), - .body => writeBody(p, req, id, pane), - .ctl => writeCtl(p, req, t.serial), - // acme's `data` and `xdata` differ only in what a READ stops - // at; the writes are the same code path there and here. - .data, .xdata => writeData(p, req, id, pane), - .tag => writeTag(p, req, pane), - .event => writeEvent(p, req, id), - .wrsel => writeWrsel(p, req, id, pane), - .errors => if (appendErrors(p, id, req.data)) |took| - .{ .tag = req.tag, .written = @intCast(took) } - else - Reply.fail(req.tag, E.IO), - .pty_ctl => writePtyCtl(p, req, id), - .pty_data => writePtyData(p, req, id), - .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), - }; - }, - } -} - -/// THE ONE BODY SPLICE every writing file goes through: replace `[q0, q1)` -/// with `bytes`, via `file_pane.setContent` — which is where the core diffs -/// out the insert/delete event records, so a script's edit is reported exactly -/// once and in exactly the same shape as a keystroke's. One swap per write for -/// the same reason: two swaps would be two `D`/`I` pairs for one write. -/// -/// The origin character the records carry is `handle`'s, set once per request -/// (acme's winlock owner), so nothing here has to know which file it is -/// serving. -fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { - const f = fileOf(pane) orelse return null; - const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); - const lo = @min(q0, f.content.len); - const hi = @max(lo, @min(q1, f.content.len)); - const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; - @memcpy(new[0..lo], f.content[0..lo]); - @memcpy(new[lo..][0..take], bytes[0..take]); - @memcpy(new[lo + take ..], f.content[hi..]); - // acme: `if(w->nomark == FALSE){ seq++; filemark(t->file); }` — `nomark` - // is how a script makes a batch of edits one Undo. - // - // COST, and the reason `nomark` matters more here than it does in acme: - // acme's `filemark` is a sequence number on a log-structured, disk-backed - // Buffer, so it is O(1). pardes's undo is a SNAPSHOT of the whole body - // (`file_pane.pushUndo` compares and then duplicates it), so a script that - // appends a line at a time to a megabyte body pays a megabyte per line and - // keeps 256 of them. That is exactly the same cost one KEYSTROKE pays on - // the same body — this is not a filesystem tax, it is the core's edit - // model — but a script can do it ten thousand times a second where a - // typist cannot. `nomark` is the documented remedy and the reason acme - // gave scripts the verb. - if (!p.fs.panes[id].nomark) file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, new); - return take; -} - -/// acme(4): "Text written to body is always appended; the file offset is -/// ignored." So `req.off` is deliberately never read here. -fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // A TERMINAL's body is not a document, it is a program's transcript — and - // the only way to put text into a transcript is to TYPE it. So a body - // write to a terminal pane is a pty write: `echo ls > $PARDES_FS/3/body` - // runs ls in pane 3's shell. That is `win`'s semantics in acme (the shell - // reads what you write to its window's body), reached through the effect - // the core already has instead of through a pipe. - // - // Nothing is RECORDED for it: the insert/delete diff lives in - // `file_pane.setContent`, and a terminal has no `file` to swap. The - // program's output comes back as ordinary `.output` bytes. - if (pane.file == null) { - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; - } - const at = bodyOf(pane).len; - const take = spliceBody(p, id, pane, at, at, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - if (!p.fs.panes[id].noscroll) showOffset(pane, at + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's tag is one `Text` and a write appends to all of it. pardes's tag is -/// PREFIX ++ TAIL: the prefix is chrome the core recomputes every frame (the -/// path, the dirty marker, the builtin words, the alignment gap), so bytes -/// appended to it would be gone by the next render. A tag write therefore -/// appends to the TAIL — which is the part that is a buffer, and the part a -/// script means when it writes ` Undo` into a tag. -/// -/// The tail is a fixed one-line buffer (`Pane.tag_tail`), so a write that does -/// not fit is short, and one with no room at all is ENOSPC rather than a zero -/// count the writer would retry forever. -fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - // the laid-out default tail becomes real bytes on first touch, exactly as - // it does when you click into the tag - p.seedTail(pane); - const room = pane.tag_tail.len - pane.tag_tail_len; - if (room == 0) return Reply.fail(req.tag, E.NOSPC); - const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); - @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]); - pane.tag_tail_len += take; - pane.tag_init = true; - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme(4): text written to `data` "replaces the characters addressed by the -/// addr file and sets the address to the null string at the end of the written -/// text". The file offset is ignored. -fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const pf = &p.fs.panes[id]; - clampAddr(pf, bodyOf(pane).len); - const q0: usize = pf.addr.q0; - const q1: usize = @max(q0, @as(usize, pf.addr.q1)); - const before = dotOf(pane); - // acme's winlock(w, 'F'): everything but body and tag is "an action - // through the window's other files". - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take))); - pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; - if (!pf.noscroll) showOffset(pane, q0 + take); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `wrsel` cuts the selection when the file is OPENED and inserts each -/// write at a running point after it (`w->wrselrange`). Same result, no -/// open-time mutation: each write REPLACES the selection, and because the -/// selection is left collapsed just after the inserted text, a second write -/// appends to the first exactly as `wrselrange` does. The only difference is -/// what an open and close with NO write does — acme has already emptied the -/// selection by then, this leaves the pane untouched. A filesystem that edits -/// your document when you `stat` it is a filesystem you cannot explore. -/// -/// acme also forces `nomark` for the file's lifetime so the whole stream is -/// one Undo. That needs open-time state we do not keep; a script that wants it -/// writes `nomark` to `ctl`, which is the same button with a name on it. -fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); - const d = dotOf(pane); - const q0: usize = d.q0; - const q1: usize = @max(q0, @as(usize, d.q1)); - const take = spliceBody(p, id, pane, q0, q1, req.data) orelse - return Reply.fail(req.tag, E.NOMEM); - setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -/// acme's `xfidwrite` QWaddr. Two failures, and acme has two error strings for -/// them: `Ebadaddr` (the parser stopped before the end of the expression) and -/// `Eaddr` (it parsed but did not evaluate — out of range, or no match). A -/// filesystem has one channel for "no", so both are EINVAL. -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const pf = &p.fs.panes[id]; - const text = bodyOf(pane); - clampAddr(pf, text.len); - // acme's parser stops at a newline of its own accord (`\n` reaches the - // `default:` arm), which is what lets `echo '/foo/' > addr` work from a - // shell. Trimming says the same thing without threading it through every - // arm of the state machine. - const expr = std.mem.trimEnd(u8, req.data, "\n"); - var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); - if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); - pf.addr = r; - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// THE ADDRESS LANGUAGE — acme's addr.c, byte-addressed. -// =========================================================================== - -/// mvzr PANICS on a pattern that ends inside an escape: `parseCharSet` slices -/// `in[i+1..]` and `valueFor` indexes `[0]` of it, so a trailing backslash is -/// an out-of-bounds read rather than a compile failure (pardes.zig's -/// `applySelRegex` carries the same warning about the prefix `[^\`). A live -/// typist can only reach that by accident; a SCRIPT's regex is untrusted -/// input, so it is screened here before the engine ever sees it. -fn safePattern(pat: []const u8) bool { - var i: usize = 0; - while (i < pat.len) : (i += 1) { - if (pat[i] != '\\') continue; - if (i + 1 >= pat.len) return false; - i += 1; - } - return true; -} - -/// THE ADDRESS PARSER, in acme's shape: one left-to-right pass with three -/// pieces of state — a running range, a DIRECTION (`+`/`-`/none) and a SIZE -/// (line or character) — recursing once per `,` or `;`. -/// -/// What is gone is the C. acme reads the expression through a `getc` callback -/// over a `Rune*` so one parser can serve both the filesystem and the Edit -/// language; it reports failure through two out-parameters (`evalp` for "did -/// not evaluate", `qp` for "stopped here") because it cannot return three -/// things; and it grows the regex pattern with `runerealloc` one rune at a -/// time. Here the expression is a slice, the cursor is a field, a pattern is a -/// subslice of the expression, and "did not evaluate" is `null`. -const Addr = struct { - text: []const u8, - /// `limit=addr`: regex context searches are confined to this. acme applies - /// it FORWARDS only, and so does this. - lim: ?PaneFs.Range, - expr: []const u8, - i: usize = 0, - /// One frame per `,` or `;`. acme recurses without a bound, which is fine - /// when the expression came from a person typing into a tag and is a - /// STACK OVERFLOW when it came from a script: `,,,,,...` a hundred - /// thousand deep is one write(2). A compound address deeper than this is - /// not an address anybody meant. - depth: u8 = 0, - - const max_depth = 32; - const Size = enum { char, line }; - - /// acme's `address()`. `ar` is what `.` means — and `xfidwrite` passes - /// `w->addr`, NOT the user's selection, so `.` is the CURRENT ADDRESS and - /// `addr=dot` is the only door the selection comes in by. (acme(4) - /// describes the language as "the format understood by button 3", where - /// `.` is dot; the code is the authority and this follows the code.) - fn address(a: *Addr, ar_in: PaneFs.Range) ?PaneFs.Range { - const start = a.i; - var ar = ar_in; - var r = ar_in; - var dir: u8 = 0; - var size: Size = .line; - var c: u8 = 0; - while (a.i < a.expr.len) { - const prevc = c; - c = a.expr[a.i]; - a.i += 1; - switch (c) { - ',', ';' => { - // `;` differs from `,` in one way: it makes the RIGHT side - // relative to the left one. - if (c == ';') ar = r; - if (prevc == 0) r.q0 = 0; // lhs defaults to 0 - if (a.i >= a.expr.len) { - r.q1 = clip(a.text.len); // rhs defaults to $ - } else { - if (a.depth >= max_depth) return null; - a.depth += 1; - const nr = a.address(ar) orelse return null; - a.depth -= 1; - r.q1 = nr.q1; - } - return r; - }, - '+', '-' => { - // a pending `+`/`-` with no count of its own means one - // line, unless what follows is itself an operand - if (prevc == '+' or prevc == '-') { - const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; - if (nc != '#' and nc != '/' and nc != '?') - r = a.number(r, 1, prevc, .line) orelse return null; - } - dir = c; - }, - '.', '$' => { - // both are only meaningful as the FIRST character of a - // (sub)expression; anywhere else they end the parse - if (a.i != start + 1) { - a.i -= 1; - return r; - } - r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; - dir = if (a.i < a.expr.len) '+' else 0; - }, - '#', '0'...'9' => { - var digit = c; - if (c == '#') { - if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { - a.i -= 1; - return r; - } - digit = a.expr[a.i]; - a.i += 1; - size = .char; - } - var n: u64 = digit - '0'; - while (a.i < a.expr.len) : (a.i += 1) { - const d = a.expr[a.i]; - if (d < '0' or d > '9') break; - n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); - } - r = a.number(r, @intCast(n), dir, size) orelse return null; - dir = 0; - size = .line; - }, - '/', '?' => { - const back = c == '?'; - r = a.regexp(r, a.pattern(c), back) orelse return null; - dir = 0; - size = .line; - }, - else => { - a.i -= 1; - return r; - }, - } - } - // a trailing `+` or `-` with nothing after it: one line that way - if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; - return r; - } - - /// The pattern between the delimiters, with the backslash of an escape - /// KEPT (it belongs to the regex engine, not to this parser). - /// - /// DIVERGENCE: acme closes both `/re/` and `?re?` on a `/` — its scanner - /// has no `case '?'` at all, so `?foo?` yields the pattern `foo?`, which - /// as a regex means `fo` plus an optional `o`. That is a bug you can only - /// find by reading addr.c. Here the OPENING delimiter closes. - fn pattern(a: *Addr, delim: u8) []const u8 { - const s = a.i; - while (a.i < a.expr.len) { - const c = a.expr[a.i]; - if (c == '\n') break; - a.i += 1; - if (c == '\\') { - if (a.i < a.expr.len) a.i += 1; - continue; - } - if (c == delim) return a.expr[s .. a.i - 1]; - } - return a.expr[s..a.i]; - } - - /// acme's `number()`, byte for byte — including its two oddities: a `-` - /// count from offset 0 wraps to the END of the file, and `:1-1` is legal - /// (it means `#0`) while `:1-2` is an error. - fn number(a: *Addr, r_in: PaneFs.Range, n: u32, dir: u8, size: Size) ?PaneFs.Range { - var r = r_in; - if (size == .char) { - var off: i64 = n; - if (dir == '+') { - off = @as(i64, r.q1) + n; - } else if (dir == '-') { - if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); - off = @as(i64, r.q0) - n; - } - if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; - // BYTES, and a byte offset can land inside a grapheme where acme's - // rune offset never could. Clamped to the boundary at or before - // it, which is the rule every other offset in pardes follows. - const g = clip(modal.graphemeStart(a.text, @intCast(off))); - return .{ .q0 = g, .q1 = g }; - } - var line: i64 = n; - var q0: usize = r.q0; - var q1: usize = r.q1; - switch (dir) { - '-' => { - if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { - q0 -= 1; - }; - q1 = q0; - while (line > 0 and q0 > 0) { - if (a.text[q0 - 1] == '\n') { - line -= 1; - q1 = q0; - } - q0 -= 1; - } - if (line > 1) return null; - while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - }, - '+' => { - if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { - q1 += 1; - }; - q0 = q1; - }, - else => { - q0 = 0; - q1 = 0; - }, - } - while (line > 0 and q1 < a.text.len) { - const ch = a.text[q1]; - q1 += 1; - if (ch == '\n' or q1 == a.text.len) { - line -= 1; - if (line > 0) q0 = q1; - } - } - if (line > 0) return null; - return .{ .q0 = clip(q0), .q1 = clip(q1) }; - } - - /// acme's `regexp()`. Forward runs from the END of the running range to - /// the limit (`limit=addr`, else the end of the file); backward runs from - /// its START back to the beginning. - /// - /// The engine is mvzr, the one `%s` and the selection previews already - /// use. Two of its properties come along and cannot be fixed here: `^` and - /// `$` assert against the SLICE being searched rather than against a line, - /// and `.` matches a newline like any other byte. Both are already waived - /// in pardes.zig; an address that needs a line anchor matches `\n`. - fn regexp(a: *Addr, r: PaneFs.Range, pat: []const u8, back: bool) ?PaneFs.Range { - // acme reuses the LAST compiled expression for an empty pattern - // (`rxnull`). There is no such global here — one more piece of hidden - // state for a script to guess wrong about — so `//` is not an address. - if (pat.len == 0 or !safePattern(pat)) return null; - const re = mvzr.compile(pat) orelse return null; - if (back) { - const hi = @min(@as(usize, r.q0), a.text.len); - var best: ?mvzr.Match = null; - var at: usize = 0; - while (at < hi) { - const m = re.matchPos(at, a.text[0..hi]) orelse break; - best = m; - at = if (m.end > m.start) m.end else m.end + 1; - } - const m = best orelse return null; - return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; - } - const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; - const from = @min(@as(usize, r.q1), hi); - const m = re.match(a.text[from..hi]) orelse return null; - return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; - } -}; - -// =========================================================================== -// CTL VERBS — acme's xfidctlwrite. -// =========================================================================== - -/// The verbs that mean something here. acme matches PREFIXES with `strncmp` -/// and advances by the matched length, which is why its arms have to be -/// ordered `delete` before `del`, `nomark` before `mark`, `noscroll` before -/// `scroll` — get that ordering wrong and a verb is silently truncated into a -/// different one. Splitting on the newline the man page already requires and -/// matching WHOLE tokens makes that class of bug unrepresentable. -const Verb = enum { - @"addr=dot", - clean, - cleartag, - del, - delete, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - put, - scroll, - show, -}; - -/// ...and the ones acme has that pardes REFUSES. Loudly, because a silently -/// accepted no-op is the worse failure: the script believes it holds the lock. -/// -/// menu / nomenu — acme maintains `Undo Redo Put` in the LEFT HALF of the -/// tag and these switch that off. pardes's tag prefix is computed chrome -/// (the path, the dirty marker, the pane's own builtins) with no halves -/// and no writable menu region, so there is nothing to switch. -/// dump / dumpdir — acme's dump file stores a COMMAND that recreates a -/// window. pardes's dump (src/dump.zig) stores the window's TEXT, so a -/// recreation command has nowhere to be kept and nothing to run it. -/// font — the face belongs to the SHELL, not the core: on a tty it is the -/// terminal emulator's and in the browser it is the page's. The `Font` -/// builtin only ASKS; a ctl verb that looked like it set one would be a -/// lie on three of the four platforms. -/// lock / unlock — acme's exclusive-use lock is a `QLock` held against a 9P -/// fid. There is no fid here and the core is single-threaded, so a lock -/// would promise a mutual exclusion nothing can violate and nothing -/// provides. -const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' '; -} - -/// acme's ctl write is NOT atomic: it applies verbs until one fails, then -/// answers `Ebadctl` with a count of the bytes it got through, so -/// `dirty\nbogus\n` leaves the window dirty and the write "fails". A short -/// count on a Linux write is not read as "the rest failed" by anybody, so the -/// only honest translation is all-or-nothing: validate every verb first, then -/// apply. `ctlVerb` answers the same yes/no in both passes. -fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - // `del`'s guard is the one predicate that reads state EARLIER VERBS IN - // THE SAME WRITE change, so the validation pass has to model it or the - // two passes disagree: `clean\ndel` (acme's own idiom, and what - // examples/acmefs/life.py sends on the way out) would fail validation - // while `dirty\ndel` would pass it and then fail half-applied. - var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - // `del` and `delete` remove the pane, and the verbs after them in - // the same write have nothing left to act on. - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - if (!ctlVerb(p, live, line, apply, &dirty)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One verb. `apply` false is the validation pass and must change nothing but -/// `dirty`, which both passes advance identically so that `del`'s guard sees -/// the same answer in each. -fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) bool { - const pane = p.panes[id] orelse return false; - const pf = &p.fs.panes[id]; - - // The one verb with an argument. acme rejects a name containing any - // character `<= ' '` and an empty one; so does this. - if (verbIs(line, "name")) { - if (line.len <= 5) return false; - const name = std.mem.trim(u8, line[5..], " \t"); - if (name.len == 0) return false; - for (name) |c| if (c <= ' ') return false; - if (!apply) return true; - const f = fileOf(pane) orelse return true; // a terminal has no name to set - const copy = p.gpa.dupe(u8, name) catch return true; - p.gpa.free(f.path); - f.path = copy; - return true; - } - for (refused_verbs) |w| if (verbIs(line, w)) return false; - - const v = std.meta.stringToEnum(Verb, line) orelse return false; - // acme: `del` is "delete, but check dirty", `delete` is "delete for sure". - // pardes's `Del` builtin is unconditional (the guard there is the `*` you - // can see in the tag), so `del` gets acme's guard here and `delete` does - // not — which is the whole difference between the two words. - if (v == .del and dirty.*) return false; - switch (v) { - .dirty => dirty.* = true, - .clean, .get, .put => dirty.* = false, - else => {}, - } - if (!apply) return true; - - switch (v) { - .@"addr=dot" => pf.addr = dotOf(pane), - .@"dot=addr" => { - clampAddr(pf, bodyOf(pane).len); - setDot(pane, pf.addr); - }, - .@"limit=addr" => { - clampAddr(pf, bodyOf(pane).len); - pf.limit = pf.addr; - }, - // acme marks the window clean by resetting the file's sequence number; - // pardes's equivalent is "the revision on screen IS the saved one". - .clean => if (fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - // acme: "wipe tag right of bar". pardes's bar is the boundary between - // the computed prefix and the editable tail, so this empties the tail - // — and leaves it SEEDED, or the next render would put the default - // builtins straight back. - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .del, .delete => _ = p.executeBuiltinLine(id, "Del"), - .put => _ = p.executeBuiltinLine(id, "Save"), - // acme's `get`: "Equivalent to the Get interactive command with no - // arguments". pardes has no such builtin, so this is what Get would - // be — the same synchronous read `file_pane.open` does, through the - // same content swap, with an undo point in front of it so a script - // cannot discard your edits irrecoverably. - .get => if (fileOf(pane)) |f| { - if (output_pane.fileTraits(f.output).saves) { - if (look.readFile(p.gpa, f.path)) |bytes| { - file_pane.pushUndo(p, pane); - file_pane.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |_| {} - } - }, - // acme's `mark` both cancels `nomark` AND pushes a mark, so the edits - // made while nomark was on stay one Undo and the next one starts fresh. - .mark => { - pf.nomark = false; - file_pane.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => showOffset(pane, dotOf(pane).q0), - } - return true; -} - -// =========================================================================== -// `pty/ctl` VERBS — the ioctls, as words. -// =========================================================================== - -/// THE WHOLE GRAMMAR, one verb per line, blank lines ignored, each line -/// trimmed and split on blanks: -/// -/// winsize two decimals, each 1..65535 -/// sig one of INT, TERM, HUP, QUIT, KILL -/// exec no argument -/// -/// An enum and an exhaustive switch for the same reason `Verb` above is one: -/// adding a word is a compile error until it is handled, and matching WHOLE -/// tokens makes acme's ordering bug (`del` shadowing `delete`) unrepresentable. -const PtyVerb = enum { winsize, sig, exec }; - -/// A `winsize` field. -/// -/// ZERO IS REFUSED. `TIOCSWINSZ` reads a zero as "unknown", so `winsize 0 24` -/// would not be a narrow terminal, it would be a terminal of no known width — -/// which is what a program sees when nobody has set a size at all, and never -/// something a script asked for on purpose. -fn ptyDimension(word: []const u8) ?u16 { - if (word.len == 0 or word.len > 5) return null; - for (word) |c| if (c < '0' or c > '9') return null; - const n = std.fmt.parseInt(u16, word, 10) catch return null; - return if (n == 0) null else n; -} - -/// `sig`'s argument: the five names, upper case, spelled the way `kill -INT` -/// and `trap` spell them. -/// -/// NOT A NUMBER, and not `SIGINT` either. A number would be one platform's -/// number in a tree meant to be read from another machine, and the core has no -/// signal numbers of its own (see `pardes.PtySignal`); the `SIG` prefix has -/// been optional to `kill` since 1988 and carrying it here would mean -/// accepting both spellings or refusing the shorter one people type. -fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { - if (std.mem.eql(u8, word, "INT")) return .int; - if (std.mem.eql(u8, word, "TERM")) return .term; - if (std.mem.eql(u8, word, "HUP")) return .hup; - if (std.mem.eql(u8, word, "QUIT")) return .quit; - if (std.mem.eql(u8, word, "KILL")) return .kill; - return null; -} - -/// VALIDATE EVERY VERB, THEN APPLY — `writeCtl`'s shape, for `writeCtl`'s -/// reason: acme applies verbs until one fails and answers with a byte count of -/// how far it got, and nothing on Linux reads a short count on a `write(2)` as -/// "the rest failed", so all-or-nothing is the only honest translation. -/// -/// Simpler than `writeCtl` in exactly one way, and it is worth saying why the -/// two passes need no shared bookkeeping here: no verb in this file can remove -/// the pane or change what a later verb in the same write would decide. `ctl` -/// has `del`, whose guard reads state `clean` sets, so its passes have to -/// model each other; these three are independent, so the validation pass is a -/// pure predicate. -fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { - for ([2]bool{ false, true }) |apply| { - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -/// One `pty/ctl` verb. `apply` false is the validation pass and must change -/// nothing whatsoever — not even a queued effect, which is the only state -/// these three touch. -fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { - const pane = p.panes[id] orelse return false; - var words = std.mem.tokenizeAny(u8, line, " \t"); - // the line is non-empty and trimmed, so there is always a first token - const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; - switch (v) { - // `TIOCSWINSZ`, and DELIBERATELY NOTHING ELSE — in particular not the - // core's own grid. - // - // A pane's grid size is not a free variable here: `Pardes.sync` derives - // `pane.cols`/`pane.rows` from the pane's RECTANGLE at the end of every - // update, so a script that wrote them would have them overwritten - // before its write returned — and `sync` would then emit a second - // `resize_pty` putting the pty back to the layout's size, so the verb - // would visibly undo itself. Telling only the pty leaves the script's - // size in force until the pane's rectangle actually changes, which for - // a layout nobody is dragging is for good. - // - // Which is also why a `winsize` write is not read back from `status`: - // `status` reports the grid the editor computed, the only size the core - // has. What a program was last TOLD is remembered by the pty, and the - // pty will not say. - .winsize => { - const cols = ptyDimension(words.next() orelse return false) orelse return false; - const rows = ptyDimension(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); - }, - // The one genuinely new capability in the whole `pty/` directory: - // there is no `kill` anywhere in the host seam until this effect. - .sig => { - const which = ptySignalNamed(words.next() orelse return false) orelse return false; - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); - }, - // RESPAWN THIS PANE'S SHELL, and NO ARGUMENT — which is a limit of the - // effect and not a choice made here. `Effect.spawn` carries a pane and - // a cwd (pardes.zig) and has nowhere to put an argv; the host answers - // it by forking `core.shellBin()`, and the argv it builds is the - // prompt-integration rc files, not something a caller supplies. So - // `exec` respawns the configured shell in the pane's own directory, - // and `exec /bin/sh` is EINVAL — refused loudly rather than accepted - // and silently ignored, which is the failure a script cannot see. - // - // Giving it an argv means widening the effect and teaching four hosts - // to exec something the user did not configure, which is a change to - // what a terminal pane IS and wants its own argument. - // - // The host reaps the old child and forks a new one (every `push_spawn` - // opens by doing exactly that, because the core has no close effect). - // The GRID is not cleared: a terminal's body is a transcript, and the - // transcript of the shell that just died is the thing a script would - // want to read afterwards. - .exec => { - if (words.next() != null) return false; - if (!apply) return true; - p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); - }, - } - return true; -} - -/// `pty/data`, write side: TYPE AT THE PROGRAM. -/// -/// Identical to what a `body` write to a terminal already does (`writeBody`), -/// and that is the point of the name rather than a duplication: `body` is a -/// pty write because a transcript can only be written by typing, `pty/data` is -/// a pty write because it IS the pty. A script that knows it is talking to a -/// terminal says so; one that is generic over panes writes `body`. -/// -/// The offset is ignored — a stream has no offsets — and the count is short at -/// a character boundary exactly as every other write here is, so a caller -/// whose buffer was split mid-sequence by the kernel's `max_write` retries the -/// tail instead of having it dropped. -fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { - if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - const take = wholeUtf8(req.data); - p.emitWrite(id, req.data[0..take]); - return .{ .tag = req.tag, .written = @intCast(take) }; -} - -// =========================================================================== -// EVENT WRITE-BACK — acme's xfideventwrite. -// =========================================================================== - -const EventRecord = struct { action: Action, q0: u32, q1: u32 }; - -/// `{origin}{type}{q0} {q1}\n`, acme's `xfideventwrite` parse: two characters, -/// two blank-separated decimals, a newline. Everything a full record carries -/// after that — the flag, the count, the text — is omitted on the way back in, -/// which is what acme(4) means by "with the flag, count, and text omitted". -/// -/// acme walks this with `strtoul`, pointer arithmetic and `goto Rescue`; here -/// the failure is `null` and the position stays in the struct, so the caller -/// can tell "ran out cleanly" from "stopped on garbage" by looking at `i`. -const EventReader = struct { - data: []const u8, - i: usize = 0, - - fn next(er: *EventReader) ?EventRecord { - if (er.i >= er.data.len) return null; - var i = er.i; - if (i + 2 > er.data.len) return null; - // acme stores the first character as `w->owner` (with a - // `/* disgusting */` beside it) so later records inherit whatever the - // writer claimed. Read and dropped here — see `writeEvent`. - i += 1; - const action = Action.fromChar(er.data[i]) orelse return null; - i += 1; - const q0 = scanNumber(er.data, &i) orelse return null; - const q1 = scanNumber(er.data, &i) orelse return null; - while (i < er.data.len and er.data[i] == ' ') i += 1; - if (i >= er.data.len or er.data[i] != '\n') return null; - er.i = i + 1; - return .{ .action = action, .q0 = q0, .q1 = q1 }; - } -}; - -fn scanNumber(data: []const u8, i: *usize) ?u32 { - while (i.* < data.len and data[i.*] == ' ') i.* += 1; - const s = i.*; - var n: u64 = 0; - while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) - n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); - if (i.* == s) return null; - return @intCast(n); -} - -/// Writing a record back PERFORMS the action it names, "exactly as it would -/// have been if the event file had not been open" (acme(4)). This is the -/// documented remote-control door and the point of the whole suppression rule: -/// a script reads an `X` record, decides the text is not one of its own tag -/// commands, and hands it back for pardes to run. -/// -/// It is also, deliberately, arbitrary code execution — an `X` record is an -/// Exec — which is why the mount is 0700 under the user's runtime directory. -/// -/// NOTHING in the write applies unless all of it parses: acme validates each -/// record just before executing it and leaves the earlier ones done, which -/// makes a malformed batch half-applied and unrepeatable. -fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { - const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); - const serial = pane0.serial; - { - const body = bodyOf(pane0); - const tag = tagOf(p, pane0); - var check: EventReader = .{ .data = req.data }; - while (check.next()) |r| { - switch (r.action) { - // acme accepts only `xXlL` on the way back in. A `D` or an `I` - // is a REPORT, not a request; writing one back would mean - // "pretend the user typed this", which nothing implements and - // acme's switch rejects with `Ebadevent`. - .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return Reply.fail(req.tag, E.INVAL), - } - // lower case is the tag, upper case the body — how a reader tells - // the two texts apart with no extra field - const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); - } - if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); - } - // acme(4): `F` is "actions through the window's other files", which is - // exactly what this is, and `handle` has already set it. acme takes the - // origin from the RECORD instead (`w->owner = *p++`, with a - // `/* disgusting */` beside it), so a writer can attribute its own action - // to the keyboard; the character is parsed here and dropped, because a - // record saying where it came from is worth nothing if the sender picks. - var run: EventReader = .{ .data = req.data }; - while (run.next()) |r| { - // an earlier action in this same write may have deleted the pane - const now = p.paneBySerial(serial) orelse break; - const pane = p.panes[now].?; - const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); - const lo = @min(@as(usize, r.q0), whole.len); - const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); - // the action can replace the very text it is reading from - const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; - switch (r.action) { - .body_exec, .tag_exec => _ = p.execute(now, text), - .body_look, .tag_look => p.lookAt(now, text), - else => unreachable, - } - } - return .{ .tag = req.tag, .written = @intCast(req.data.len) }; -} - -// =========================================================================== -// +Errors — acme's `errorwin`. -// =========================================================================== - -/// acme(4): writing to `errors` "appends to the body of the dir/+Errors -/// window, where dir is the directory currently named in the tag. The window -/// is created if necessary, but not until text is actually written." -/// -/// One buffer per DIRECTORY, not per pane — which is why a search for an -/// existing one matches on the dirname and not on the writer. Answers HOW -/// MANY BYTES WERE TAKEN, or null for failure. -/// -/// The count matters because the append goes through `spliceBody`, which stops -/// at a whole-character boundary: the kernel splits a large `write(2)` at -/// `max_write` wherever it lands, so a multi-byte character straddling that -/// boundary must be reported short and retried by the writer's libc, exactly -/// as `body` and `data` do. Acknowledging the whole buffer would drop it. -fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { - if (text.len == 0) return 0; - const pane = p.panes[id] orelse return null; - const dir = dirOf(pane); - for (p.panes, 0..) |slot, i| { - const q = slot orelse continue; - const qf = fileOf(q) orelse continue; - const o = qf.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; - return spliceBody(p, i, q, qf.content.len, qf.content.len, text); - } - const free = p.freeSlot() orelse return null; - const content = p.gpa.dupe(u8, text) catch return null; - const np = output_pane.open(p, free, dir, .errors, "", content) catch { - p.gpa.free(content); - return null; - }; - p.placeDoc(id, free, np); - return text.len; -} - -// =========================================================================== -// SIZES — what `stat` reports. -// =========================================================================== - -/// The whole `index`, measured. acme's `xfidindexread` walks every window to -/// size its buffer too; the tag of each is FORMATTED to be measured, into the -/// per-update scratch arena that is reset anyway, so this is bump allocation -/// rather than sixteen allocations a frame. -fn indexLen(p: *Pardes) u64 { - var n: u64 = 0; - for (p.panes) |slot| { - const pane = slot orelse continue; - n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; - } - return n; -} - -/// A terminal's body has no length that is cheap AND honest — measuring it -/// means rendering the whole scrollback — so it reports zero and is served -/// with direct IO, exactly like `event` and `log`. -fn bodyLen(p: *Pardes, pane: *Pane) u64 { - _ = p; - return bodyOf(pane).len; -} - -fn tagLen(p: *Pardes, pane: *Pane) u64 { - return tagOf(p, pane).len; -} - -// =========================================================================== -// TESTS. -// -// The whole point of the split: every one of these drives `handle()` through -// the ordinary event queue with NO FUSE, NO mount, NO thread and no /dev/fuse -// anywhere. A filesystem whose semantics are a pure function of the core is a -// filesystem you can unit-test at the speed of a function call, and one whose -// blocking is a return value is one you can test without a scheduler. -// =========================================================================== - -const testing = std.testing; - -/// What a transport sees: the reply, and the bytes `fsPayload` resolved for it -/// inside the drain's borrow window. The two effects a filesystem operation -/// can additionally cause are captured too, because for `put` and for a write -/// to a terminal's body THE EFFECT IS THE ANSWER. -const Answer = struct { - reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, - bytes: []const u8 = "", - saved: bool = false, - pty_buf: [256]u8 = undefined, - pty_len: usize = 0, - /// `pty/ctl`'s three verbs are each ONE EFFECT and nothing else, so the - /// effect is the only thing a test can look at. - winsize: ?struct { cols: u16, rows: u16 } = null, - signal: ?pardes.PtySignal = null, - spawned: bool = false, - - fn pty(a: *const Answer) []const u8 { - return a.pty_buf[0..a.pty_len]; - } - - fn errno(a: Answer) u16 { - return if (a.reply.status == .err) a.reply.errno else 0; - } -}; - -/// One request in, one answer out. Effects are DRAINED but not performed: a -/// `put` must be observable as a `.save_file` without a test writing to the -/// real filesystem. -fn call(p: *Pardes, req: Req) Answer { - p.update(.{ .fs_req = req }); - var ans: Answer = .{}; - while (p.nextEffect()) |e| switch (e) { - .fs_reply => |r| { - ans.reply = r; - ans.bytes = p.fsPayload(r); - }, - .save_file, .save_text => ans.saved = true, - .write => |w| { - const b = w.bytes.slice(); - const n = @min(b.len, ans.pty_buf.len - ans.pty_len); - @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); - ans.pty_len += n; - }, - .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, - .signal_pty => |s| ans.signal = s.sig, - .spawn => ans.spawned = true, - else => {}, - }; - return ans; -} - -fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { - return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); -} - -fn wr(p: *Pardes, node: u64, data: []const u8) Answer { - return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); -} - -fn rdir(p: *Pardes, node: u64, skip: u64) Answer { - return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); -} - -fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { - return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); -} - -/// A core with one FILE pane holding `text`, which is what most of acme's -/// window files are about. Slot 0, and its serial is the directory name. -fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - _ = try p.hxOpenFileContent(text); - while (p.nextEffect()) |_| {} - return p; -} - -/// ...and a core whose slot 0 is a TERMINAL, which is what `pty/` is about. -/// `tty_only` opens exactly one shell pane and nothing else, so there is no -/// document anywhere and the geometry has already settled by the time the -/// startup effects are drained — a later `.resize_pty` in a test is therefore -/// one a verb caused. -fn withTerm(gpa: std.mem.Allocator) !*Pardes { - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); - errdefer p.deinit(); - while (p.nextEffect()) |_| {} - std.debug.assert(p.panes[0].?.isTerminal()); - return p; -} - -fn serialOf(p: *Pardes) u32 { - return p.panes[0].?.serial; -} - -const Dirent = struct { node: u64, dir: bool, name: []const u8 }; - -/// Decode the readdir staging format `src/fuse.zig` agreed to. -fn dirents(bytes: []const u8, out: []Dirent) []Dirent { - var n: usize = 0; - var i: usize = 0; - while (i + 10 <= bytes.len and n < out.len) { - const node = std.mem.readInt(u64, bytes[i..][0..8], .little); - const kind = bytes[i + 8]; - const len = bytes[i + 9]; - i += 10; - if (i + len > bytes.len) break; - out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; - i += len; - n += 1; - } - return out[0..n]; -} - -fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { - for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; - return null; -} - -test "readdir lists the root, a pane directory, and new/ without creating anything" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - var buf: [32]Dirent = undefined; - - const root = rdir(p, @intFromEnum(TopFile.root), 0); - try testing.expectEqual(Status.ok, root.reply.status); - const top = dirents(root.bytes, &buf); - try testing.expectEqual(@as(usize, 4), top.len); - try testing.expectEqualStrings("index", top[0].name); - try testing.expectEqualStrings("cons", top[1].name); - try testing.expectEqualStrings("new", top[2].name); - try testing.expect(top[2].dir and !top[0].dir); - var idbuf: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&idbuf, "{d}", .{serial}), top[3].name); - try testing.expect(top[3].dir); - // the id a readdir reports is the id a getattr will report - try testing.expectEqual(Node.of(serial, .dir), top[3].node); - - // `off` skips entries, and past the end is EOF, not an error - const rest = rdir(p, @intFromEnum(TopFile.root), 3); - try testing.expectEqual(@as(usize, 1), dirents(rest.bytes, &buf).len); - const eof = rdir(p, @intFromEnum(TopFile.root), 99); - try testing.expectEqual(Status.ok, eof.reply.status); - try testing.expectEqual(@as(usize, 0), eof.bytes.len); - - const dir = rdir(p, Node.of(serial, .dir), 0); - const files = dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); // dirtabw minus "." - try testing.expect(nameAt(files, "addr") != null); - try testing.expect(nameAt(files, "xdata") != null); - try testing.expect(nameAt(files, ".") == null); - try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); - - // acme(4) says accessing a file in `new` creates a window, so LISTING it - // must enumerate nothing at all: every name it could report is a name - // whose lookup creates a pane, and `ls -l` stats what a listing reported. - const before = p.next_serial; - const new = rdir(p, @intFromEnum(TopFile.new), 0); - try testing.expectEqual(Status.ok, new.reply.status); - try testing.expectEqual(@as(usize, 0), new.bytes.len); - try testing.expectEqual(before, p.next_serial); - - // a file is not a directory - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); -} - -test "lookup resolves top files, pane serials and pane files" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const root = @intFromEnum(TopFile.root); - - try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); - - var idbuf: [16]u8 = undefined; - const dir = look_up(p, root, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})); - try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); - try testing.expect(dir.reply.attr.dir); - // a serial that is not a live pane, and a serial that never existed - try testing.expectEqual(E.NOENT, look_up(p, root, "99999").errno()); - - const body = look_up(p, Node.of(serial, .dir), "body"); - try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); - // a lookup answers exactly what a getattr of the same node would - const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); - try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); -} - -test "a lookup inside new/ creates a pane and resolves that pane's file" { - const gpa = testing.allocator; - const p = try withFile(gpa, "first\n"); - defer p.deinit(); - const before = serialOf(p); - - // a name that is not a pane file creates nothing - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "bogus").errno()); - try testing.expectEqual(before, p.next_serial); - - const a = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, a.reply.status); - const made: Node = @bitCast(a.reply.attr.node); - try testing.expect(made.serial != before); - try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); - - // ...and it is a real pane: `echo hi > new/body` leaves a pane holding hi - _ = wr(p, a.reply.attr.node, "hi"); - const id = p.paneBySerial(@intCast(made.serial)).?; - try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); -} - -test "index prints winctlprint's five fields then the tag" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\nthere\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, @intFromEnum(TopFile.index), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var got: [512]u8 = undefined; - @memcpy(got[0..a.bytes.len], a.bytes); - const line = got[0..a.bytes.len]; - - const tag = tagOf(p, pane); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ - pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), - }); - try testing.expectEqualStrings(want.items, line); - // acme(4): "at character position 5x12 starts the name of the window" - try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); - - // seekable: a script may pread the middle of it - const mid = rd(p, @intFromEnum(TopFile.index), 60, 5); - try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); - - // ...and a dirty pane says so in the fifth field - pane.file.?.saved_revision = pane.file.?.revision -% 1; - const dirty = rd(p, @intFromEnum(TopFile.index), 48, 12); - try testing.expectEqualStrings(" 1 ", dirty.bytes); -} - -test "ctl read is index's five fields plus width in cells, font and tab width" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - - const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); - try testing.expectEqual(Status.ok, a.reply.status); - var want: std.ArrayList(u8) = .empty; - defer want.deinit(gpa); - try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ - pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, - }); - try testing.expectEqualStrings(want.items, a.bytes); - - // plan9 %q: a name with a space in it becomes one shell word - var quoted: std.ArrayList(u8) = .empty; - defer quoted.deinit(gpa); - stageQuoted("ed, gpa, "DejaVu Sans Mono"); - try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); - quoted.clearRetainingCapacity(); - stageQuoted("ed, gpa, "it's"); - try testing.expectEqualStrings("'it''s'", quoted.items); -} - -test "body reads at any offset and writes append" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const body = Node.of(serial, .body); - - try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); - try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); - try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); - try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); - // zero copy: the answer points INTO the pane, it is not a staged copy - try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); - - // acme(4): "Text written to body is always appended; the file offset is - // ignored" — so a write at offset 0 still lands at the end. - const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); - - // a write cut mid-character is SHORT, never split - const short = wr(p, body, "a\xC3"); - try testing.expectEqual(@as(u32, 1), short.reply.written); - try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); -} - -test "a body write to a terminal pane types at its shell" { - const gpa = testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.isTerminal()); - - // `win`'s transcript semantics: the only way into a program's transcript - // is to type at it, so a body write becomes a pty write. - const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); - try testing.expectEqual(@as(u32, 6), a.reply.written); - try testing.expectEqualStrings("ls -l\r", a.pty()); - - // and a body READ renders the scrollback rather than lending a buffer - const r = rd(p, Node.of(pane.serial, .body), 0, 64); - try testing.expectEqual(Status.ok, r.reply.status); -} - -test "tag reads the whole tag and writes append to the editable tail" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const pane = p.panes[0].?; - const node = Node.of(pane.serial, .tag); - - const whole = rd(p, node, 0, 4096); - try testing.expect(std.mem.startsWith(u8, whole.bytes, "/hxcase.txt")); - try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); - - const before = rd(p, node, 0, 4096).bytes.len; - const w = wr(p, node, " Mine"); - try testing.expectEqual(@as(u32, 5), w.reply.written); - try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine")); - const after = rd(p, node, 0, 4096); - try testing.expectEqual(before + 5, after.bytes.len); - try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); - - // the tail is one bounded line; with no room left the file is FULL - pane.tag_tail_len = pane.tag_tail.len; - try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); -} - -test "the address language, form by form" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\nthree\n"); // 14 bytes, three lines - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - - const Case = struct { expr: []const u8, q0: u32, q1: u32 }; - for ([_]Case{ - .{ .expr = "#0", .q0 = 0, .q1 = 0 }, - .{ .expr = "#5", .q0 = 5, .q1 = 5 }, - .{ .expr = "0", .q0 = 0, .q1 = 0 }, - .{ .expr = "1", .q0 = 0, .q1 = 4 }, - .{ .expr = "2", .q0 = 4, .q1 = 8 }, - .{ .expr = "$", .q0 = 14, .q1 = 14 }, - .{ .expr = ",", .q0 = 0, .q1 = 14 }, - .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, - .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, - .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, - .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, - .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, - .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, - // a trailing newline is what a shell redirect leaves behind - .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, - }) |c| { - // every case starts from a known address, so `.` and `+`/`-` are - // measured against the same place each time - _ = wr(p, addr, "#0"); - const w = wr(p, addr, c.expr); - try testing.expectEqual(Status.ok, w.reply.status); - const got = rd(p, addr, 0, 64); - var want: [32]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), - got.bytes, - ); - } - - // `.` is the CURRENT ADDRESS (acme passes w->addr as `ar`), not the - // selection: set it, then ask for it back. - _ = wr(p, addr, "1"); - _ = wr(p, addr, "."); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); - - // `?re?` searches BACKWARD from the start of the running range and takes - // the LAST match before it — acme's `rxbexecute`. - _ = wr(p, addr, "$"); - _ = wr(p, addr, "?o?"); - try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" - try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); - - // limit=addr confines a forward search - _ = wr(p, addr, "1"); - _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); - _ = wr(p, Node.of(serial, .ctl), "clean\n"); // any ctl write; limit stays - // ...and opening ctl clears it again (acme(4)) - _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); - try testing.expect(p.fs.panes[0].limit == null); - _ = wr(p, addr, "#0"); - try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); - - // refusals - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); - } - - // acme recurses once per `,` with no bound at all, which a script turns - // into a stack overflow with one write(2). Refused, not crashed. - const nested = "," ** 4096; - _ = wr(p, addr, "#0"); - try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno()); -} - -test "data and xdata read from addr, move it, and write through it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const addr = Node.of(serial, .addr); - const data = Node.of(serial, .data); - const xdata = Node.of(serial, .xdata); - - _ = wr(p, addr, "#0"); - try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); - // ...and the address is now the null string after what was returned - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - // xdata stops at the END of the address where data would run on - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); - _ = wr(p, addr, "1"); - try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); - - // a write REPLACES the addressed text and leaves the address after it - _ = wr(p, addr, "1"); - const w = wr(p, data, "ONE\n"); - try testing.expectEqual(@as(u32, 4), w.reply.written); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0); -} - -test "data never splits a grapheme, in either direction" { - const gpa = testing.allocator; - const p = try withFile(gpa, "\u{00e9}x\n"); // é is two bytes - defer p.deinit(); - const serial = serialOf(p); - _ = wr(p, Node.of(serial, .addr), "#0"); - // one byte is not enough for the first character: acme's `if(m == 0) break` - try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); - - // and a write ending mid-character is short rather than corrupting - _ = wr(p, Node.of(serial, .addr), "#0"); - try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); -} - -test "rdsel reads the selection and wrsel replaces it" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); - try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); - - // ...and the round trip back out is the identity, not a range that creeps - _ = wr(p, ctl, "addr=dot\n"); - try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); - try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status); - try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); - // a second write appends after the first, acme's `wrselrange` - _ = wr(p, Node.of(serial, .wrsel), "!"); - try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); -} - -test "every ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - // several verbs in one write, which is what the man page promises - try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); - - _ = wr(p, ctl, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - - try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - // acme rejects a name with any character <= ' ' in it - try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); - try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); - try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); - - // `put` is acme's Put, which is pardes's Save - try testing.expect(wr(p, ctl, "put\n").saved); - - // REFUSED, each for a reason that is not "unimplemented" — see - // `refused_verbs`. Silently accepting these is the worse failure. - for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); - - // ATOMIC, which acme is not: an unknown verb aborts the WHOLE write. - try testing.expect(!dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); - try testing.expect(!dirtyOf(pane)); -} - -test "ctl get reloads the pane from disk and del honours a dirty body" { - const gpa = testing.allocator; - var tmp = testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/note.txt", .{tmp.sub_path}); - - const p = try withFile(gpa, "in memory\n"); - defer p.deinit(); - const serial = serialOf(p); - const ctl = Node.of(serial, .ctl); - const pane = p.panes[0].?; - - var name: [std.fs.max_path_bytes + 8]u8 = undefined; - _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); - try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); - try testing.expectEqualStrings("from disk\n", pane.file.?.content); - // Get leaves the pane clean and the previous text one Undo away - try testing.expect(!dirtyOf(pane)); - try testing.expect(pane.file.?.undo_len > 0); - - // acme: `del` is "delete, but check dirty"; `delete` is "delete for sure" - _ = wr(p, ctl, "dirty\n"); - try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); - try testing.expect(p.paneBySerial(serial) != null); - // ...and a second pane so the last one closing does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); - try testing.expect(p.paneBySerial(serial) == null); -} - -test "errors and cons append to one +Errors buffer per directory" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - - const live = for (p.panes) |slot| { - if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; - } else null; - try testing.expect(live == null); // "not until text is actually written" - - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); - _ = wr(p, @intFromEnum(TopFile.cons), "again\n"); - - var found: usize = 0; - for (p.panes) |slot| { - const q = slot orelse continue; - const f = q.file orelse continue; - const o = f.output orelse continue; - if (std.meta.activeTag(o.from) != .errors) continue; - found += 1; - try testing.expectEqualStrings("boom\nagain\n", f.content); - try testing.expectEqualStrings("/+Errors", f.path); - } - try testing.expectEqual(@as(usize, 1), found); -} - -test "setattr truncation empties the body and answers fresh attributes" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - - const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(@as(u64, 0), a.reply.attr.size); - try testing.expectEqualStrings("", p.panes[0].?.file.?.content); - - // `> body` then a write is the shell's way of REPLACING a pane's text - _ = wr(p, Node.of(serial, .body), "new text\n"); - try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); - - // a setattr that sets no size changes nothing - const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); - try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); -} - -test "event records are acme's bytes, one per read, and .again when empty" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - - // nothing is recorded while nobody is listening - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); - try testing.expect(p.fs.panes[0].events.empty()); - - const h = call(p, .{ .tag = 10, .op = .open, .node = event }); - try testing.expect(h.reply.handle != 0); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - // an empty queue is `.again`: nothing consumed, ask me later. NEVER an - // error, and never a loop. - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - p.fs.origin = 'M'; - _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); - _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); - // `%c%c%d %d %d %d %s\n`, wind.c's winevent with the owner char in front - try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); - try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); - - // one record per read: a read too small to hold one is refused rather - // than answered with half a record the reader cannot resynchronise from - _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); - try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); - try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); - - // text of 256 bytes or more is elided; the reader fetches it from `data` - const big = "z" ** max_record_text; - _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); - try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); - - _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -/// Drain a queue into `store` and return the records. Reading is destructive -/// and a `.staged` answer is only valid until the next request, so each record -/// is copied out as it arrives. -fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { - var used: usize = 0; - var n: usize = 0; - while (n < out.len) { - const a = rd(p, node, 0, 4096); - if (a.reply.status != .ok) break; - @memcpy(store[used..][0..a.bytes.len], a.bytes); - out[n] = store[used..][0..a.bytes.len]; - used += a.bytes.len; - n += 1; - } - return out[0..n]; -} - -test "a write through the filesystem is reported once, attributed to the file it came through" { - const gpa = testing.allocator; - const p = try withFile(gpa, "one\ntwo\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - _ = call(p, .{ .tag = 40, .op = .open, .node = event }); - var store: [4096]u8 = undefined; - var slots: [16][]const u8 = undefined; - _ = drainEvents(p, event, &store, &slots); - - // A body write is acme's `E`: "writes to the body or tag file". ONE pair - // per write, because the diff lives in `file_pane.setContent` and a write - // is one content swap — that is the contract with the core's hook, and - // emitting records from the handler as well is what it forbids. - _ = wr(p, Node.of(serial, .body), "three\n"); - const body_recs = drainEvents(p, event, &store, &slots); - try testing.expect(body_recs.len >= 1); - // ...and the record's TEXT here contains a newline of its own, which is - // exactly why `Queue` frames records by length instead of by line - try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); - // the write also made the pane dirty, so its TAG changed — and acme - // attributes that to the write too (`winsettag` runs inside the same - // `winlock(w, 'E')`), which is why the origin is set for the whole - // request and not just for the mutation. - for (body_recs[1..]) |r| { - try testing.expectEqual(@as(u8, 'E'), r[0]); - try testing.expect(Action.fromChar(r[1]).?.onTag()); - } - - // A `data` write is acme's `F`: "actions through the window's other - // files" — and a replacement is a delete then an insert, acme's order, - // with no text on the delete. - _ = wr(p, Node.of(serial, .addr), "1"); - _ = wr(p, Node.of(serial, .data), "ONE\n"); - const data_recs = drainEvents(p, event, &store, &slots); - try testing.expectEqual(@as(usize, 2), data_recs.len); - try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); - try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); - try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); -} - -test "two event readers each count once, and the second closing leaves the first" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const event = Node.of(serialOf(p), .event); - - _ = call(p, .{ .tag = 12, .op = .open, .node = event }); - _ = call(p, .{ .tag = 13, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - // A release names the NODE, not a handle: FUSE carries the nodeid on every - // request, so there is no fid table to look one up in, and one release - // answers for one open. - _ = call(p, .{ .tag = 14, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); - try testing.expect(p.fs.scripted(0)); // the pane is STILL script-driven - - // a release with nothing left to release changes nothing and is not an - // error, and neither is one naming a node that never counted - _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); - try testing.expectEqual(@as(u16, 1), p.fs.listeners); - - _ = call(p, .{ .tag = 16, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - _ = call(p, .{ .tag = 17, .op = .release, .node = event }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); -} - -test "a pane deleted while its event file is open leaves no suppression behind" { - const gpa = testing.allocator; - const p = try withFile(gpa, "x\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - // a second pane, so deleting the first does not quit the editor - _ = look_up(p, @intFromEnum(TopFile.new), "body"); - - const a = call(p, .{ .tag = 18, .op = .open, .node = event }); - const b = call(p, .{ .tag = 19, .op = .open, .node = event }); - try testing.expectEqual(@as(u16, 2), p.fs.listeners); - - _ = wr(p, Node.of(serial, .ctl), "delete\n"); - try testing.expect(p.paneBySerial(serial) == null); - // the core's `State.forget` took BOTH readers out with the pane - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // ...and the two late releases must not underflow it back to 65535, which - // would suppress every button action in the editor forever - _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); - _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - - // every operation on the dead pane is ENOENT — acme's Edel - try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); -} - -test "writing an event record back performs the action it names" { - const gpa = testing.allocator; - const p = try withFile(gpa, "Msg fs-ran\n"); - defer p.deinit(); - const serial = serialOf(p); - const event = Node.of(serial, .event); - const pane = p.panes[0].?; - - // an `X` record over the body text `Msg fs-ran` is an Exec of it - const w = wr(p, event, "FX0 10\n"); - try testing.expectEqual(Status.ok, w.reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // several records in one write - pane.msg_len = 0; - try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); - try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); - - // ...and nothing applies when any of it is malformed: acme's Ebadevent - pane.msg_len = 0; - for ([_][]const u8{ - "FX0 10\nFQ0 1\n", // unknown type character - "FX0 999\n", // out of range - "FX0 10", // no newline - "FX5 1\n", // q0 > q1 - "FD0 3\n", // a report, not a request - "F\n", - }) |bad| { - try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); - try testing.expectEqual(@as(usize, 0), pane.msg_len); - } - - // The action is attributed to the FILESYSTEM (`F`), never to whatever the - // writer put in the record's origin character — acme copies that byte - // into `w->owner` and lets a script claim its Exec came from the - // keyboard. - _ = call(p, .{ .tag = 23, .op = .open, .node = event }); - p.fs.origin = 'K'; - _ = wr(p, event, "KX0 10\n"); - try testing.expectEqual(@as(u8, 'F'), p.fs.origin); -} - -test "a pane that is not a terminal has no pty/ at all" { - const gpa = testing.allocator; - const p = try withFile(gpa, "hello\n"); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - // ABSENT, not present-and-refusing: `-d $PARDES_FS//pty` is how a - // script asks whether a pane is a terminal. - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 1, - .op = .getattr, - .node = Node.of(serial, .pty), - }).errno()); - try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); - try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); - // ...and an OPEN too, so the reader count that gates the raw queue can - // never be armed on a pane that has no pty to produce bytes - try testing.expectEqual(E.NOENT, call(p, .{ - .tag = 2, - .op = .open, - .node = Node.of(serial, .pty_data), - }).errno()); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - - // ...and the listing is byte for byte the ten entries it always was - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - try testing.expect(nameAt(files, "pty") == null); - - // the enum's spelling is not a name in the tree: `pty_ctl` is how the flat - // enum spells `pty/ctl`, and neither directory answers to it - try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); - try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); - - // `new/` makes a scratch, which can never be a terminal, so naming a pty - // file there creates nothing at all - const before = p.next_serial; - try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(TopFile.new), "pty").errno()); - try testing.expectEqual(before, p.next_serial); -} - -test "a terminal pane's pty/ holds exactly ctl, status and data" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const dir = Node.of(serial, .dir); - - const pty = look_up(p, dir, "pty"); - try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); - try testing.expect(pty.reply.attr.dir); - try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); - - var buf: [32]Dirent = undefined; - const files = dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); // the ten, plus pty - try testing.expect(nameAt(files, "pty").?.dir); - - const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); - try testing.expectEqualStrings("ctl", inside[0].name); - try testing.expectEqualStrings("status", inside[1].name); - try testing.expectEqualStrings("data", inside[2].name); - for (inside) |d| try testing.expect(!d.dir); - // the ids a listing reports are the ids a lookup resolves - try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); - - // ...and the two namespaces do not leak into each other - const ctl = look_up(p, Node.of(serial, .pty), "ctl"); - try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); - try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); - try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); - try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); - - // a file is not a directory, on either side of the slash - try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); - try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); - // and the directory itself is not read(2)able, nor is a write-only file - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); - try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); - try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); -} - -test "every pty/ctl verb, and every refusal" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const ctl = Node.of(serialOf(p), .pty_ctl); - - // winsize reaches the effect queue, and ONLY the pty: the grid belongs to - // the layout, so the pane's own cols/rows are untouched. - const pane = p.panes[0].?; - const cols = pane.cols; - const rows = pane.rows; - const ws = wr(p, ctl, "winsize 132 44\n"); - try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); - try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); - try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); - try testing.expectEqual(cols, pane.cols); - try testing.expectEqual(rows, pane.rows); - - // all five signal names, and no others - for ([_]struct { line: []const u8, want: pardes.PtySignal }{ - .{ .line = "sig INT", .want = .int }, - .{ .line = "sig TERM", .want = .term }, - .{ .line = "sig HUP", .want = .hup }, - .{ .line = "sig QUIT", .want = .quit }, - .{ .line = "sig KILL", .want = .kill }, - }) |c| { - const a = wr(p, ctl, c.line); - try testing.expectEqual(Status.ok, a.reply.status); - try testing.expectEqual(c.want, a.signal.?); - } - - // exec respawns the shell: the same effect `newShell` emits - const ex = wr(p, ctl, "exec\n"); - try testing.expectEqual(Status.ok, ex.reply.status); - try testing.expect(ex.spawned); - - // several verbs in one write, no trailing newline needed - const both = wr(p, ctl, "winsize 100 30\nsig TERM"); - try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); - try testing.expectEqual(pardes.PtySignal.term, both.signal.?); - - // ...and EVERY malformed line refuses the WHOLE batch, so the good verb - // beside it never reached the queue. Two passes, one applied. - for ([_][]const u8{ - "winsize", // no arguments - "winsize 80", // one argument - "winsize 80 24 extra", // three - "winsize 0 24", // zero is "unknown", never a width - "winsize 80 0", - "winsize -1 24", // not a decimal - "winsize 999999 24", // wider than a u16 - "sig", // no name - "sig INT TERM", // two - "sig SIGINT", // the prefix `kill` dropped in 1988 - "sig int", // lower case - "sig 9", // a number is one platform's number - "sig USR1", // a real signal, deliberately not offered - "exec /bin/sh", // the effect carries no argv; refused, never ignored - "raw", // the draft's TCSETS line, which the core cannot answer - "cooked", - "winsize 80 24\nbogus", // a good verb beside a bad one - "bogus\nwinsize 80 24", - "name x", // a `ctl` verb; the two files share no vocabulary - "del", - }) |bad| { - const a = wr(p, ctl, bad); - try testing.expectEqual(E.INVAL, a.errno()); - try testing.expect(a.winsize == null); - try testing.expect(a.signal == null); - try testing.expect(!a.spawned); - } - - // blank lines and surrounding space are not verbs and not errors - const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); - try testing.expectEqual(Status.ok, spaced.reply.status); - try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); - // an empty write is a write of nothing - try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); -} - -/// A host that answers `pull_tty_taken` and nothing else, so `pty/status`'s -/// third field can be tested with no pty anywhere. The same shape -/// `pardes.zig`'s own `FakeTtyQuery` has, spelled again here because that one -/// is private to its own tests. -const FakeTty = struct { - taken: bool, - - const vtable: pardes.Host.VTable = .{ .pull_tty_taken = answer }; - - fn answer(ctx: ?*anyopaque, pane: u8) bool { - _ = pane; - const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); - return f.taken; - } -}; - -test "pty/status reports the grid and who holds the tty" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const pane = p.panes[0].?; - const status = Node.of(pane.serial, .pty_status); - - const a = rd(p, status, 0, 256); - try testing.expectEqual(Status.ok, a.reply.status); - var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); - try testing.expectEqualStrings(whole, a.bytes); - // three `%11d ` fields, like `ctl` and `index`, and seekable like both. - // The expectation is compared against `want` and not against `a.bytes`, - // which the NEXT request's staging invalidates — the borrow window this - // whole module is built on. - try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); - try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); - - // the third field is `pull_tty_taken`, the probe the core already has - var probe: FakeTty = .{ .taken = true }; - p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; - const held = rd(p, status, 0, 256); - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), - held.bytes, - ); -} - -test "pty/data writes at the shell and reads the raw stream" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const serial = serialOf(p); - const data = Node.of(serial, .pty_data); - - // WRITE is a pty write, exactly as a body write to a terminal is, and the - // offset is ignored because a stream has none - const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); - try testing.expectEqual(@as(u32, 6), w.reply.written); - try testing.expectEqualStrings("ls -l\r", w.pty()); - // short at a character boundary, never split, never zero for real bytes - try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); - try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); - - // READ blocks — `.again`, nothing consumed — while there is nothing there - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // THE READER COUNT IS THE GATE: output arriving at a pane nobody is - // reading is not recorded, so the queue stays empty and the pane pays - // nothing for a filesystem it is not using. - p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - // ...and it is NOT the event-suppression gate: reading a terminal's output - // is not claiming the pane's buttons. - try testing.expectEqual(@as(u16, 0), p.fs.listeners); - try testing.expect(!p.fs.scripted(0)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // UNFRAMED: a read smaller than one arrival is served and the remainder - // kept, because raw pty bytes have no records to split down the middle. - // `event` refuses exactly this read; that is the difference, on purpose. - p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); - try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); - // ...and a read SPANS arrivals, which one read(2) on the pty would too - p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); - - // the LAST reader leaving gives the memory back and drops what is stale - p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); - while (p.nextEffect()) |_| {} - _ = call(p, .{ .tag = 6, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); - try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); - try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); - - // two readers: the second closing leaves the first still recording - _ = call(p, .{ .tag = 7, .op = .open, .node = data }); - _ = call(p, .{ .tag = 8, .op = .open, .node = data }); - _ = call(p, .{ .tag = 9, .op = .release, .node = data }); - try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); - p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); - while (p.nextEffect()) |_| {} - try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); -} - -test "the pty queue drops the oldest at its cap" { - const gpa = testing.allocator; - const p = try withTerm(gpa); - defer p.deinit(); - const data = Node.of(serialOf(p), .pty_data); - _ = call(p, .{ .tag = 5, .op = .open, .node = data }); - - // A script that opens the file and stops reading must BOUND the editor, - // not grow it. The oldest arrivals go; a reader that fell this far behind - // has lost the thread anyway and can re-read `body` to resynchronise. - const oldest: [4096]u8 = @splat('A'); - const rest: [4096]u8 = @splat('B'); - notePtyOutput(p, 0, &oldest); - for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); - // LIVE bytes, not the buffer: `Queue` pops by moving `head` and reclaims - // the space lazily (`compact`), so the allocation trails the contents by - // design and the cap is a bound on what is still owed to a reader. - const q = &p.fs.panes[0].pty_out; - try testing.expect(q.buf.items.len - q.head <= queue_cap); - - var seen: usize = 0; - while (true) { - const a = rd(p, data, 0, 1 << 16); - if (a.reply.status == .again) break; - try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); - if (a.bytes.len == 0) break; - seen += a.bytes.len; - } - try testing.expect(seen > 0 and seen <= queue_cap); -} diff --git a/src/allocators.zig b/src/allocators.zig deleted file mode 100644 index 61c55f39..00000000 --- a/src/allocators.zig +++ /dev/null @@ -1,104 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const limits = @import("limits.zig"); - -const Allocator = std.mem.Allocator; -const debug_enabled = builtin.mode == .Debug; - -pub const Allocators = struct { - pardes: Allocator, - frame: Allocator, - lsp: Allocator, - tree_sitter: Allocator, - image: Allocator, - pdf: Allocator, -}; - -/// The static reservations, one per profile tier. See `limits.arena` for why -/// each number is what it is, and why the board's are 4 KiB and zero. -var pardes_fallback: std.heap.StackFallbackAllocator(limits.arena.pardes) = undefined; -var frame_fallback: std.heap.StackFallbackAllocator(limits.arena.frame) = undefined; -var tree_sitter_fallback: std.heap.StackFallbackAllocator(limits.arena.tree_sitter) = undefined; -var image_fallback: std.heap.StackFallbackAllocator(limits.arena.image) = undefined; -var pdf_fallback: std.heap.StackFallbackAllocator(limits.arena.pdf) = undefined; - -const Debug = std.heap.DebugAllocator(.{}); -var pardes_debug: Debug = .init; -var frame_debug: Debug = .init; -var lsp_debug: Debug = .init; -var tree_sitter_debug: Debug = .init; -var image_debug: Debug = .init; -var pdf_debug: Debug = .init; - -/// Returns ordinary allocators backed by stdlib fixed-buffer fallbacks. LSP -/// keeps the caller's allocator because its detached workers are concurrent. -pub fn init(fallback: Allocator) Allocators { - pardes_fallback.fallback_allocator = fallback; - pardes_fallback.get_called = if (std.debug.runtime_safety) false else {}; - frame_fallback.fallback_allocator = fallback; - frame_fallback.get_called = if (std.debug.runtime_safety) false else {}; - tree_sitter_fallback.fallback_allocator = fallback; - tree_sitter_fallback.get_called = if (std.debug.runtime_safety) false else {}; - image_fallback.fallback_allocator = fallback; - image_fallback.get_called = if (std.debug.runtime_safety) false else {}; - pdf_fallback.fallback_allocator = fallback; - pdf_fallback.get_called = if (std.debug.runtime_safety) false else {}; - - const raw: Allocators = .{ - .pardes = pardes_fallback.get(), - .frame = frame_fallback.get(), - .lsp = fallback, - .tree_sitter = tree_sitter_fallback.get(), - .image = image_fallback.get(), - .pdf = pdf_fallback.get(), - }; - if (!debug_enabled) return raw; - - pardes_debug = .{ .backing_allocator = raw.pardes }; - frame_debug = .{ .backing_allocator = raw.frame }; - lsp_debug = .{ .backing_allocator = raw.lsp }; - tree_sitter_debug = .{ .backing_allocator = raw.tree_sitter }; - image_debug = .{ .backing_allocator = raw.image }; - pdf_debug = .{ .backing_allocator = raw.pdf }; - return .{ - .pardes = pardes_debug.allocator(), - .frame = frame_debug.allocator(), - .lsp = lsp_debug.allocator(), - .tree_sitter = tree_sitter_debug.allocator(), - .image = image_debug.allocator(), - .pdf = pdf_debug.allocator(), - }; -} - -pub fn deinit() void { - if (!debug_enabled) return; - var leaked = pardes_debug.deinit() == .leak; - leaked = (frame_debug.deinit() == .leak) or leaked; - leaked = (lsp_debug.deinit() == .leak) or leaked; - leaked = (tree_sitter_debug.deinit() == .leak) or leaked; - leaked = (image_debug.deinit() == .leak) or leaked; - leaked = (pdf_debug.deinit() == .leak) or leaked; - if (leaked) @panic("allocator leaks detected"); -} - -test "fixed allocators are separate, spill, and restart" { - var allocs = init(std.testing.allocator); - const core = try allocs.pardes.alloc(u8, 32); - const frame = try allocs.frame.alloc(u8, 32); - try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(core.ptr)); - try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr)); - try std.testing.expect(core.ptr != frame.ptr); - - const spill = try allocs.pardes.alloc(u8, limits.arena.pardes + 1); - try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr)); - allocs.pardes.free(spill); - allocs.frame.free(frame); - allocs.pardes.free(core); - deinit(); - - allocs = init(std.testing.allocator); - defer deinit(); - const restarted = try allocs.pardes.alloc(u8, 32); - defer allocs.pardes.free(restarted); - try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(restarted.ptr)); -} diff --git a/src/animation.zig b/src/animation.zig deleted file mode 100644 index c22396b8..00000000 --- a/src/animation.zig +++ /dev/null @@ -1,189 +0,0 @@ -//! Small, backend-neutral fixed-step animations. -//! -//! A transition always interpolates from its saved endpoints. It never folds -//! the rounded value from one frame into the next, so channels are monotonic, -//! completion is exact, and a different backend cadence cannot accumulate a -//! different rounding error. Values opt in by providing -//! `interpolate(from, to, step, steps)`. -const std = @import("std"); - -/// Frontends aim for one animation step per display frame. Ten 16 ms steps is -/// deliberately short: enough to make a palette change legible without -/// turning theme browsing into something the user has to wait through. -pub const frame_ms: u32 = 16; -pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; -pub const transition_steps: u16 = 10; - -pub fn Transition(comptime Value: type) type { - return struct { - const Self = @This(); - - from: Value, - to: Value, - displayed: Value, - step: u16 = transition_steps, - - pub fn init(value: Value) Self { - return .{ .from = value, .to = value, .displayed = value }; - } - - pub fn isActive(a: *const Self) bool { - return a.step < transition_steps; - } - - /// Begin again from the value on screen, not the old target. This is - /// what makes a mid-flight retarget continuous. - pub fn retarget(a: *Self, target: Value) void { - a.from = a.displayed; - a.to = target; - a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; - if (a.step == transition_steps) a.displayed = target; - } - - pub fn advance(a: *Self) void { - if (!a.isActive()) return; - a.step += 1; - // Assign the endpoint directly. Besides documenting the contract, - // this keeps exact completion independent of an interpolator's - // internal rounding choices. - a.displayed = if (a.step == transition_steps) - a.to - else - Value.interpolate(a.from, a.to, a.step, transition_steps); - } - - /// Initialization and dump restore use snap: their first frame is the - /// selected theme, never an animation from a compiled-in default. - pub fn snap(a: *Self, value: Value) void { - a.* = init(value); - } - }; -} - -/// `Transition`'s interface with the animation taken OUT: a value that is only ever the one it was -/// last set to. -/// -/// This exists so that a build which never fades does not carry the machinery for fading. A runtime -/// flag around the same `Transition` cannot achieve that - the endpoints stay in the struct and -/// `Value.interpolate` stays in the binary, reachable and therefore emitted. Selecting a different -/// type at comptime is what makes the interpolator genuinely unreachable, and on a target whose whole -/// display is a 115200-baud serial line, absent code and unspent frames are the same saving twice. -/// -/// Every method here is the trivial one, and `retarget` is deliberately `snap` rather than an error: -/// callers ask for a new palette and get it, on the next frame, in one step. Nothing about the -/// interface says how many frames the arrival takes. -pub fn Immediate(comptime Value: type) type { - return struct { - const Self = @This(); - - displayed: Value, - - pub fn init(value: Value) Self { - return .{ .displayed = value }; - } - - pub fn isActive(_: *const Self) bool { - return false; - } - - pub fn retarget(a: *Self, target: Value) void { - a.displayed = target; - } - - pub fn advance(_: *Self) void {} - - pub fn snap(a: *Self, value: Value) void { - a.displayed = value; - } - }; -} - -/// Linear RGB interpolation with nearest-integer rounding. The weighted-sum -/// form stays unsigned for both rising and falling channels. -pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { - if (step == 0) return from; - if (step >= steps) return to; - var out: [3]u8 = undefined; - for (&out, from, to) |*dst, a, b| { - const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; - dst.* = @intCast((numerator + steps / 2) / steps); - } - return out; -} - -const TestColor = struct { - rgb: [3]u8, - - pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { - return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; - } -}; - -// The substitute has to be interchangeable, and the property that matters is the one a caller could -// otherwise get wrong: it must arrive at the SAME palette a completed fade arrives at. A fade whose -// endpoint differed by a rounding step would make the build option a visible change of colors rather -// than a change of how long they take. -test "Immediate lands where a completed Transition lands" { - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - - var faded = Transition(TestColor).init(from); - faded.retarget(to); - for (0..transition_steps) |_| faded.advance(); - - var instant = Immediate(TestColor).init(from); - try std.testing.expect(!instant.isActive()); - instant.retarget(to); - try std.testing.expectEqual(faded.displayed, instant.displayed); - - // Never active, so a frontend that renders only while something is animating stops immediately - // rather than spending ten frames discovering there is nothing to draw. - try std.testing.expect(!instant.isActive()); - instant.advance(); - try std.testing.expectEqual(to, instant.displayed); - - instant.snap(from); - try std.testing.expectEqual(from, instant.displayed); -} - -test "fixed-step interpolation has exact monotonic endpoints" { - const Tween = Transition(TestColor); - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - var tween = Tween.init(from); - tween.retarget(to); - try std.testing.expectEqual(from, tween.displayed); - - var previous = tween.displayed; - for (0..transition_steps) |_| { - tween.advance(); - try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); - try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); - try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); - previous = tween.displayed; - } - try std.testing.expect(!tween.isActive()); - try std.testing.expectEqual(to, tween.displayed); - tween.advance(); - try std.testing.expectEqual(to, tween.displayed); -} - -test "retarget starts at the currently displayed value" { - const Tween = Transition(TestColor); - const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; - const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; - const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; - var tween = Tween.init(first); - tween.retarget(second); - tween.advance(); - tween.advance(); - tween.advance(); - const on_screen = tween.displayed; - - tween.retarget(third); - try std.testing.expectEqual(on_screen, tween.from); - try std.testing.expectEqual(on_screen, tween.displayed); - try std.testing.expect(tween.isActive()); - for (0..transition_steps) |_| tween.advance(); - try std.testing.expectEqual(third, tween.displayed); -} diff --git a/src/board9p.zig b/src/board9p.zig deleted file mode 100644 index b5b18bf2..00000000 --- a/src/board9p.zig +++ /dev/null @@ -1,874 +0,0 @@ -//! THE BOARD AS A FILESYSTEM, generated from a comptime table of what the board can do. -//! -//! The ESP32-P4 already exposes its pads and its address space — by TYPING A WORD into a tag. -//! `Gpio 20` flips a pin and prints `GPIO 20: 0->1`, `Gpio` alone draws JP1, `Peek`, `Poke` and -//! `Hexdump` reach all 2³² addresses (`src/board_memory.zig`), and every one of the four caps at -//! 4,096 bytes because the answer has to fit down a 115200-baud console. Nothing about that is -//! machine-readable and nothing about it is remote: the answer lands in an output pane, for a person -//! to read (`docs/registry.typ` `9P-11`, review note). -//! -//! This file is that same capability WITH NAMES INSTEAD OF VERBS. `cat gpio/pinout` is `Gpio`; -//! `echo 1 > gpio/20/value` is `Gpio 20`, except that it says which level it wants instead of asking -//! for whichever one it is not. A shell pipeline can do it, a script on a laptop can do it over the -//! UART, and neither needs a terminal emulator or a pane. -//! -//! WHY A TABLE, which is the whole design and not a flourish. A hand-written tree is a `Node` -//! packing, a `lookup`, a `getattr`, a `readdir`, a `read` and a `write` — six places that have to -//! agree about what exists — and the cost of adding `uptime` to it is an edit to all six plus a new -//! node id nobody else is using. The board's capabilities are a LIST, they will grow, and the entry -//! that describes one should be the only place it is described. So `caps` below is the tree: the -//! directories, the files, the per-pin fan-out, the permissions, the handlers and even the size of -//! the answer buffer are all derived from it at comptime, and the six functions at the bottom read -//! the derived table and know nothing about GPIO at all. -//! -//! WHAT A SECOND CAPABILITY COSTS, entry by entry, because "extensible" is a claim and this is the -//! evidence for it. Not implemented here — none of them is needed to serve a pin — but each is one -//! `Cap` and its handlers, and NO tree code: -//! -//! * `mem/` — `peek` and `poke` over `board_memory.readWord`/`writeWord` -//! (`src/board_memory.zig:136-144`), which are four lines of `*allowzero volatile` and already -//! compile for this target. `poke` is a WRITE handler that parses ` `, so -//! it needs `Fault.Malformed` and nothing else; `peek` needs an address to read, which a -//! stateless file cannot carry, so it is either a write-then-read pair (`echo 4ff40000 > addr; -//! cat word`, one more file and one `u32` of state) or a fan over a comptime list of interesting -//! registers. The second is free: `fan` below already generates a directory per key. -//! * `hexdump` — the same, with `scratch = 4096`: the one field that makes the shared answer -//! buffer grow, and the reason that field is in the table rather than a constant at the top. -//! * `prof` — three cycle counts from `pardes_esp32p4_frame_prof`, which the editor object already -//! exports (`src/esp32p4.zig:985`). It is the one capability that is NOT available in this -//! image: that symbol lives in the pardes object and the 9P image links none, so serving it -//! would mean either linking the editor or moving the counters. Worth saying out loud rather -//! than listing it as cheap. -//! * `uptime` and `heap` — `hal.systimer` and the heap's own free count, both of which the -//! runtime (`src/esp32p4_9p.zig`) can reach today. Two read handlers, `scratch = 24`, one -//! `Cap` each. These are the cheapest of the four and the reason the table's `board` parameter -//! is a TYPE rather than a pair of function pointers: adding `board.uptimeMs()` to the seam -//! adds a capability without changing anything here but the table. -//! -//! THE ABI IS `acmefs`'s, VERBATIM — `Op`, `Status`, `Req`, `Reply`, `Reply.Attr` with the same -//! fields and the same meanings — so `src/9p.zig`'s `Server` serves this tree with no translation -//! layer, exactly as it serves the editor's. That is the point of `Server` being a generic over the -//! filesystem rather than an importer of one (`src/9p.zig:1994-2010`), and it is what makes a board -//! image possible at all: `acmefs.zig` reaches `pardes.zig` and the whole core, and this file -//! reaches `std` and one leaf table. -//! -//! NO ALLOCATOR, NO OS, ONE REQUEST AT A TIME. Same rules as `acmefs`: `handle(req) -> Answer` is a -//! pure transaction, the answer's bytes are either `.rodata` or the one shared buffer, and they are -//! borrowed until the next call. Nothing here blocks, so `Status.again` never appears — the board -//! has no `event` file and no reader to park. -const std = @import("std"); -const board_pins = @import("board_pins.zig"); - -/// The errno values this tree returns. `acmefs.E`'s subset — the four a tree with no panes, no -/// blocking and no allocation can produce — with the same numbers, because they are Linux's and a -/// second spelling would be a second thing to check against `9p.errString`. -pub const E = struct { - pub const NOENT: u16 = 2; - pub const IO: u16 = 5; - pub const NOTDIR: u16 = 20; - pub const INVAL: u16 = 22; -}; - -/// How a HANDLER refuses, as against how the tree refuses. The tree answers ENOENT and ENOTDIR -/// itself, out of the table, before any handler runs; this is the set of things only the handler can -/// know. -/// -/// One variant today, and it is the honest count: a pad takes `0` or `1` and nothing else. A -/// capability that can refuse for a second reason adds a variant here and a prong to `errnoOf`, -/// which is the whole of what "another kind of no" costs. -pub const Fault = error{ - /// the bytes offered are not a value this file takes - Malformed, -}; - -/// The one place a `Fault` becomes a number. -fn errnoOf(f: Fault) u16 { - return switch (f) { - error.Malformed => E.INVAL, - }; -} - -/// A file's two halves, as POINTERS rather than function types: the derived table below is an -/// ordinary runtime array, and a struct holding a bare `fn` is comptime-only. -/// -/// `key` says which pad, address or counter the call is about, and `out` is the slice of the shared -/// answer buffer this file's table entry declared — exactly `scratch` bytes, so a handler cannot -/// write past its own budget. A read may also ignore `out` entirely and answer out of `.rodata`, -/// which is what the JP1 drawing does. -const ReadFn = *const fn (key: u16, out: []u8) Fault![]const u8; -const WriteFn = *const fn (key: u16, bytes: []const u8) Fault!u32; - -/// One FILE in the table. `key` is not here: it comes from the directory the file is generated -/// into, which is what makes one entry serve eleven pins. -/// -/// The MODE is derived, never declared: a file with both handlers is 0o600, a read handler alone is -/// 0o400, a write handler alone is 0o200, and neither is a compile error. A declared mode is a -/// fourth thing that can disagree with the three that decide it. -pub const FileSpec = struct { - name: []const u8, - read: ?ReadFn = null, - write: ?WriteFn = null, - /// Bytes of the shared answer buffer this file's read needs. ZERO when the read answers out of - /// `.rodata` and copies nothing, which is what `gpio/pinout` does — the JP1 drawing is 468 - /// bytes of static text and there is no reason to stage it. The largest `scratch` in the table - /// is one of the two numbers that size `Tree.out`. - scratch: u32 = 0, -}; - -/// One generated subdirectory of a capability, and its KEY: the pad, address or counter every file -/// inside it is about. `gpio/20/value` is `key = 20`. -pub const FanDir = struct { key: u16, name: []const u8 }; - -/// A capability's fan-out: one directory per key, each holding the same files. THE REASON the tree -/// has exactly the pins this board has — the dirs are collected from `board_pins.gpio_pins`, which -/// is collected from the JP1 rows, which are the schematic. -pub const Fan = struct { dirs: []const FanDir, files: []const FileSpec }; - -/// ONE CAPABILITY = ONE DIRECTORY under the root. Always a directory, even for a capability with a -/// single file: a flat root would put every capability's names in one u4 (see `block` below) and -/// would make `ls /` a list of files whose grouping a reader has to infer. `ls /` here is the list -/// of things this board can do. -pub const Cap = struct { - name: []const u8, - files: []const FileSpec = &.{}, - fan: ?Fan = null, -}; - -/// One node of the derived tree. Flat, because a table of fifteen entries scanned linearly is -/// faster than any structure with pointers in it and is the same shape `src/9p.zig`'s own test stub -/// uses — and because a scan cannot disagree with itself about what the tree contains. -const Entry = struct { - node: u64, - /// Where `..` goes. See `block`: this is also the value `src/9p.zig`'s `parentOf` derives from - /// the node id, for every entry but a fan leaf, and the test at the bottom asserts it. - parent: u64, - name: []const u8, - dir: bool, - mode: u16, - /// the pad this file is about, or zero - key: u16 = 0, - read: ?ReadFn = null, - write: ?WriteFn = null, - scratch: u32 = 0, -}; - -/// THE NODE ID PACKING, and it is not ours: it is `acmefs.Node`'s, `{ file: u4, serial: u60 }`, -/// because `src/9p.zig:1955` `parentOf` READS node ids to answer `..` and has that packing built in. -/// A tree that numbered its nodes freely would get a wrong answer to `cd ..` and no diagnostic. -/// -/// The rule, restated as arithmetic: a node's parent is the node rounded down to a multiple of 16, -/// except that a node already at a multiple of 16 — or below 16 — is a child of the root. -/// -/// * the root is 1: serial 0, so `..` is itself, which is POSIX's rule and `intro(5)`'s. -/// * a capability directory is its own BLOCK BASE, `(index + 1) * 16`, so its `..` is the root. -/// * everything inside a capability — its files AND its fan directories — is a member of that -/// block, `base + 1 .. base + 15`, so their `..` is the capability directory. Correct, which is -/// what matters for the one `..` a client actually performs: `cd /gpio/20; cd ..`. -/// * a fan LEAF (`gpio/20/value`) cannot be expressed. Its parent is a block member, and -/// `parentOf` can only produce block bases. So leaves get blocks of their own, above every -/// capability's, and `..` from one lands on an unallocated block base, which this tree answers -/// ENOENT. That is the honest failure: a walk that cannot be expressed is refused rather than -/// silently landing on a different file. No client does it — `..` from a file requires having -/// walked INTO a file, and a file is not a directory — and the fix, if one is ever wanted, is a -/// `parent` hook on `Server` so a filesystem deeper than two levels answers `..` itself. That -/// is exactly the wall `parentOf`'s own doc comment says it is (`src/9p.zig:1950-1954`), and -/// `acmefs`'s `pty/` subtree stands on the same side of it today. -const block: u64 = 16; - -/// The root, and the value the runtime hands `Server.init` as `Options.root`. One, for the same -/// reason `acmefs.TopFile.root` is one: node 0 is `{ file: 0, serial: 0 }` and cannot be a root -/// (`src/9p.zig:2190-2192`). -pub const root: u64 = 1; - -/// Every key the GPIO fan generates, re-exported for the RUNTIME's benefit: `src/esp32p4_9p.zig` -/// checks at comptime that each one is a pad `hal.gpio` will accept, which is the one thing this -/// file cannot check for itself — `max_pin` is a property of the chip package and lives in the -/// toolchain repository, and importing it here would make the tree unbuildable on a host. -pub const pins = board_pins.gpio_pins; - -/// The board's own tree, over a `board` seam the runtime supplies. -/// -/// GENERIC over the board for exactly the reason `Server` is generic over the filesystem: the pads -/// are four register files behind `hal.gpio` in the toolchain package, which exists only for -/// riscv32, and a tree that imported it could not be tested on a host at all. The seam is two -/// functions, both about the level the board is DRIVING: -/// -/// * `board.level(pin: u8) u1` -/// * `board.drive(pin: u8, level: u1) void` -/// -/// `src/esp32p4_9p.zig` implements them over `hal.gpio`, in the same four calls -/// `src/esp32p4/app.zig:200-209` uses for the `Gpio` word — the same seam, a second caller, not a -/// second copy of the register sequence. The tests below implement them over a recording stub, the -/// way `src/9p.zig`'s server tests implement a filesystem. -pub fn Tree(comptime board: type) type { - return struct { - const Self = @This(); - - // -- the ABI, which is `acmefs`'s ------------------------------------ - // - // A MIRROR, not a redefinition: `Server(acmefs)` is the instantiation that proves the - // shape, and a field that drifts from it is a compile error the moment `Server(Tree(...))` - // is built — which the tests at the bottom do. - - pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; - - /// `again` is here because the ABI has it, and it never occurs: nothing on this board - /// blocks. The board's answer to "what is this pin at" is a register read. - pub const Status = enum(u8) { ok, again, err }; - - pub const Req = struct { - tag: u64, - op: Op, - node: u64, - handle: u32 = 0, - off: u64 = 0, - size: u32 = 0, - data: []const u8 = &.{}, - truncate: bool = false, - }; - - pub const Reply = struct { - tag: u64, - status: Status = .ok, - errno: u16 = 0, - attr: Attr = .{}, - handle: u32 = 0, - written: u32 = 0, - - pub const Attr = struct { - node: u64 = 0, - dir: bool = false, - size: u64 = 0, - mode: u16 = 0o600, - }; - - /// `acmefs.Reply.fail`'s twin, so a refusal is one expression here as it is there. - pub fn fail(tag: u64, e: u16) Reply { - return .{ .tag = tag, .status = .err, .errno = e }; - } - }; - - /// A reply and the bytes it points at, borrowed until the next `handle`. `Server.reply` - /// takes exactly this pair. - pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; - - // -- the handlers ---------------------------------------------------- - - /// `gpio/pinout` — JP1, as the `Gpio` word draws it, TO THE BYTE. The same - /// `board_pins.jp1_text` the word prints (`src/board_memory.zig:364`), returned out of - /// `.rodata` rather than staged, so this read costs no buffer and no copy. - fn readPinout(_: u16, _: []u8) Fault![]const u8 { - return board_pins.jp1_text; - } - - /// `gpio//value` — the level this board is DRIVING on pad `n`, as `0` or `1` and a - /// newline. - /// - /// THE DRIVEN LEVEL and not the pad's, for the reason `src/esp32p4/app.zig:196-199` gives: - /// the pad's own level is what the outside world says, and on an unconnected header pin that - /// is noise. The driven level is defined for every pin, which is what a file that a script - /// reads in a loop needs. - /// - /// The trailing newline is not decoration: `cat gpio/20/value` in a terminal and `$(cat - /// ...)` in a script both want it, and the write side accepts it back, so `cp` of one pin's - /// value onto another's is a legal round trip. - fn readValue(key: u16, out: []u8) Fault![]const u8 { - out[0] = '0' + @as(u8, board.level(@intCast(key))); - out[1] = '\n'; - return out[0..2]; - } - - /// `gpio//value` — drive pad `n` to `0` or `1`. - /// - /// WRITING THE OPPOSITE OF THE CURRENT LEVEL IS THE `Gpio` WORD'S TOGGLE, through the same - /// seam; writing the level it is already at is not a no-op, because the FIRST write to a pad - /// is what makes it an output at all (`hal.gpio.configureOutput`, four register files). So - /// this always drives, and `echo 0 > value` on a fresh boot is a meaningful command: it - /// takes the pad off whatever the IO MUX had it pointed at and holds it low. - /// - /// `0`, `1`, `0\n` and `1\n` are the whole language. Anything else is EINVAL, including - /// `true`, `high`, `01` and the empty write — a file whose only two values are one character - /// each has no room for a spelling debate, and guessing at `on` would be the beginning of - /// one. - fn writeValue(key: u16, bytes: []const u8) Fault!u32 { - const want = try oneBit(bytes); - board.drive(@intCast(key), want); - // The whole write is consumed, trailing newline included: a short count would make - // `echo` retry the tail and drive the pin a second time. - return @intCast(bytes.len); - } - - /// `0` or `1`, with at most one trailing newline (and the `\r` a Windows-ish client may put - /// in front of it). Nothing else. - fn oneBit(bytes: []const u8) Fault!u1 { - var end = bytes.len; - while (end > 0 and (bytes[end - 1] == '\n' or bytes[end - 1] == '\r')) end -= 1; - if (end != 1) return error.Malformed; - return switch (bytes[0]) { - '0' => 0, - '1' => 1, - else => error.Malformed, - }; - } - - // -- THE TABLE ------------------------------------------------------- - - /// The pin directories, one per P4 GPIO the header brings out, named by the pin number in - /// DECIMAL — the number the schematic, the silkscreen and the datasheet all use, and the one - /// literal in `board_memory.zig` that is not hex (`:394-399`). Generated from - /// `board_pins.gpio_pins`, so this list cannot contain a pin JP1 does not have. - const gpio_dirs = dirs: { - var out: [board_pins.gpio_pins.len]FanDir = undefined; - for (board_pins.gpio_pins, 0..) |pin, i| out[i] = .{ - .key = pin, - .name = std.fmt.comptimePrint("{d}", .{pin}), - }; - break :dirs out; - }; - - /// EVERYTHING THIS BOARD OFFERS, and the only place any of it is described. The tree, the - /// permissions, the handlers, the node ids and the answer buffer all come out of here. - const caps = [_]Cap{ - .{ - .name = "gpio", - .files = &.{ - .{ .name = "pinout", .read = readPinout }, - }, - .fan = .{ - .dirs = &gpio_dirs, - .files = &.{ - .{ .name = "value", .read = readValue, .write = writeValue, .scratch = 2 }, - }, - }, - }, - }; - - /// How many nodes the table generates, counted separately because it is an array length. - const node_count = count: { - var n: usize = 1; // the root - for (caps) |c| { - n += 1 + c.files.len; - if (c.fan) |f| n += f.dirs.len * (1 + f.files.len); - } - break :count n; - }; - - /// THE DERIVED TREE. Built once at comptime and `const`, so it lands in `.rodata` and costs - /// the image its bytes and the board's RAM nothing. - const table: [node_count]Entry = build: { - var out: [node_count]Entry = undefined; - out[0] = .{ .node = root, .parent = root, .name = "/", .dir = true, .mode = 0o500 }; - var at: usize = 1; - // Blocks 1..caps.len are the capability directories; fan leaves take the ones above, - // which is what keeps a leaf's unexpressible parent from landing on a real node. - var next_block: u64 = caps.len + 1; - for (caps, 0..) |c, ci| { - const dir_node = (ci + 1) * block; - out[at] = .{ .node = dir_node, .parent = root, .name = c.name, .dir = true, .mode = 0o500 }; - at += 1; - // The u4 in the node id, spent one per name inside this capability. Directories and - // files come out of the same fifteen, which is the wall `acmefs.PaneFile`'s doc - // comment describes from the other side. - var slot: u64 = 1; - for (c.files) |f| { - out[at] = fileEntry(dir_node + slot, dir_node, f, 0); - at += 1; - slot += 1; - } - if (c.fan) |fan| for (fan.dirs) |d| { - const fan_node = dir_node + slot; - slot += 1; - out[at] = .{ .node = fan_node, .parent = dir_node, .name = d.name, .dir = true, .mode = 0o500 }; - at += 1; - const leaf_base = next_block * block; - next_block += 1; - for (fan.files, 0..) |f, l| { - out[at] = fileEntry(leaf_base + 1 + l, fan_node, f, d.key); - at += 1; - } - }; - if (slot >= block) @compileError( - "capability '" ++ c.name ++ - "' has more than 15 names in it, and a node id has four bits for them:" ++ - " `acmefs.Node.file` is a u4 and `9p.parentOf` reads it. Split it into two" ++ - " capabilities, or widen the packing in acmefs.zig, 9p.zig and here at once.", - ); - } - break :build out; - }; - - /// One file's entry, with the mode derived from which handlers it has. - fn fileEntry(node: u64, parent: u64, f: FileSpec, key: u16) Entry { - const mode: u16 = if (f.read != null and f.write != null) - 0o600 - else if (f.read != null) - 0o400 - else if (f.write != null) - 0o200 - else - @compileError("file '" ++ f.name ++ "' has no read and no write, so it is a name and not a file"); - return .{ - .node = node, - .parent = parent, - .name = f.name, - .dir = false, - .mode = mode, - .key = key, - .read = f.read, - .write = f.write, - .scratch = f.scratch, - }; - } - - /// THE ONE BUFFER, and both numbers that size it come out of the table: the largest - /// `scratch` any read declares, and the widest directory's worth of staged entries. Never - /// both at once — one request is in flight at a time — so one buffer serves both, and the - /// board pays for the larger. - const out_max = size: { - var most: usize = 0; - for (table) |e| most = @max(most, e.scratch); - for (table) |d| { - if (!d.dir) continue; - var n: usize = 0; - for (table) |e| if (e.parent == d.node and e.node != d.node) { - n += dirent_fixed + e.name.len; - }; - most = @max(most, n); - } - break :size most; - }; - - /// `node[8] dir[1] namelen[1]` — `acmefs`'s staging format for a readdir - /// (`acmefs.zig:942-957`), which is what `Server` decodes. Ten bytes and then the name. - const dirent_fixed = 8 + 1 + 1; - - /// Formatted answers and staged directory entries. Valid until the next `handle`, which is - /// the borrow window `Server.reply` documents. - out: [out_max]u8 = undefined, - - /// Every request the board has been asked, for the runtime's own diagnostics. Not a - /// protocol counter — `Server` keeps those — and not a statistic anybody has to read: it is - /// the one number that distinguishes "nothing is arriving" from "everything is being - /// refused" on a board with no second console to ask. - calls: u32 = 0, - - fn find(node: u64) ?*const Entry { - for (&table) |*e| if (e.node == node) return e; - return null; - } - - fn attrOf(t: *Self, e: *const Entry) Reply.Attr { - return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = t.sizeOf(e) }; - } - - /// A file's size is WHAT ITS READ ANSWERS, asked rather than declared. That means a - /// `getattr` of `gpio/20/value` reads the pad's output register, which is a load from a - /// peripheral and nothing more; the alternative is a second declaration in the table that - /// can disagree with the handler, on a tree whose whole claim is that there is one place per - /// fact. A write-only file has no size and reports zero, which is what `acmefs` reports for - /// every file it cannot cheaply measure. - fn sizeOf(t: *Self, e: *const Entry) u64 { - const read = e.read orelse return 0; - const bytes = read(e.key, t.out[0..e.scratch]) catch return 0; - return bytes.len; - } - - /// ONE OPERATION, and the whole of what this filesystem is. Pure: no allocation, no - /// blocking, no state but `out` and the counter. - pub fn handle(t: *Self, req: Req) Answer { - t.calls += 1; - const e = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; - switch (req.op) { - .lookup => { - if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; - for (&table) |*c| { - if (c.parent != req.node or c.node == req.node) continue; - if (!std.mem.eql(u8, c.name, req.data)) continue; - return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(c) } }; - } - return .{ .reply = .fail(req.tag, E.NOENT) }; - }, - .getattr => return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }, - // The only `setattr` that reaches here is a truncate, from `Topen` with `OTRUNC` - // (`src/9p.zig:2816-2822`) — which is what `echo 1 > gpio/20/value` opens with. - // Every file here is a fixed-length register view, so there is nothing to truncate - // and nothing to refuse either: answering EINVAL would make the shell's own - // redirection fail on a pin that is perfectly writable. - .setattr => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }; - }, - // No per-open state, so one handle for every open. `Server` checks the mode against - // the fid's cached permissions before it gets here (`src/9p.zig:2075-2079`). - .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, - .release => return .{ .reply = .{ .tag = req.tag } }, - .read => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - const read = e.read orelse return .{ .reply = .fail(req.tag, E.INVAL) }; - const all = read(e.key, t.out[0..e.scratch]) catch |f| { - return .{ .reply = .fail(req.tag, errnoOf(f)) }; - }; - // Past the end is the empty read every client uses to stop, not an error. - if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } }; - const from = all[@intCast(req.off)..]; - return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] }; - }, - .write => { - if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; - const write = e.write orelse return .{ .reply = .fail(req.tag, E.INVAL) }; - // A REGISTER IS NOT A STREAM. Every file here is one value, so the only offset - // that means anything is zero; a client that seeks and writes is describing an - // edit to a byte range this file does not have. `echo`, `9p write` and - // `cat > file` all write at zero. - if (req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; - const n = write(e.key, req.data) catch |f| { - return .{ .reply = .fail(req.tag, errnoOf(f)) }; - }; - return .{ .reply = .{ .tag = req.tag, .written = n } }; - }, - .readdir => { - if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; - return .{ .reply = .{ .tag = req.tag }, .bytes = t.stage(req.node, req.off) }; - }, - // 9P2000 has no `Tstatfs` — that is a `.L` message (`src/9p.zig:24-28`) — so - // nothing reaches this. It is answered rather than `unreachable` because the ABI - // names it and a panic in a server is worse than an empty answer. - .statfs => return .{ .reply = .{ .tag = req.tag } }, - } - } - - /// A directory's children in `acmefs`'s staging format, from an ENTRY INDEX rather than a - /// byte offset — `Server` does that coordinate change and advances both cursors - /// (`src/9p.zig:2836-2849`). The whole of the widest directory fits `out` by construction, - /// so this never stages a short list for want of room; `Server` still takes only what one - /// reply holds and asks again. - fn stage(t: *Self, node: u64, skip: u64) []const u8 { - var n: usize = 0; - var seen: u64 = 0; - for (&table) |*e| { - if (e.parent != node or e.node == node) continue; - if (seen < skip) { - seen += 1; - continue; - } - std.mem.writeInt(u64, t.out[n..][0..8], e.node, .little); - t.out[n + 8] = @intFromBool(e.dir); - t.out[n + 9] = @intCast(e.name.len); - @memcpy(t.out[n + dirent_fixed ..][0..e.name.len], e.name); - n += dirent_fixed + e.name.len; - } - return t.out[0..n]; - } - }; -} - -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// A RECORDING STUB FOR THE PADS, exactly as `src/9p.zig`'s server tests use a stub filesystem: the -// seam is two functions, so the test can hold the pads still and check what was asked of them. Every -// claim below is one a host can answer — the tree's shape, the bytes of an answer, which pin the -// seam was called with — and the one claim it cannot is stated as such: whether `hal.gpio` drives -// the pad, which only the die knows. - -const testing = std.testing; - -/// The pads, faked. `driven` is the board's output register. -const StubPads = struct { - var driven: [64]u1 = @splat(0); - var log: [16]Call = undefined; - var log_len: usize = 0; - - const Call = struct { pin: u8, level: u1 }; - - fn reset() void { - driven = @splat(0); - log_len = 0; - } - - fn level(pin: u8) u1 { - return driven[pin]; - } - - fn drive(pin: u8, want: u1) void { - driven[pin] = want; - log[log_len] = .{ .pin = pin, .level = want }; - log_len += 1; - } -}; - -const Board = Tree(StubPads); - -/// The tree, walked by name the way a client walks it: `lookup` after `lookup` from the root, which -/// is the only way to find out what the generated table actually offers. -fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { - var at: u64 = root; - var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; - for (path) |name| { - const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); - if (a.reply.status == .err) return switch (a.reply.errno) { - E.NOENT => error.NoEntry, - E.NOTDIR => error.NotDirectory, - else => error.Refused, - }; - attr = a.reply.attr; - at = attr.node; - } - return attr; -} - -fn readAll(t: *Board, node: u64) !Board.Answer { - const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); - try testing.expectEqual(Board.Status.ok, open.reply.status); - return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); -} - -test "board9p: the generated tree has exactly the header's pins, and nothing else" { - var t: Board = .{}; - - // The capability directory, and its one hand-written file. - try testing.expect((try walk(&t, &.{"gpio"})).dir); - try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); - - // Every pin JP1 brings out is a directory with a `value` in it. Eleven of them, generated. - for (board_pins.gpio_pins) |pin| { - var name: [4]u8 = undefined; - const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); - try testing.expect((try walk(&t, &.{ "gpio", dir })).dir); - const value = try walk(&t, &.{ "gpio", dir, "value" }); - try testing.expect(!value.dir); - try testing.expectEqual(@as(u16, 0o600), value.mode); - } - - // And a pin the board does not bring out is not there. 6 and 21 are real ESP32-P4 GPIOs that - // JP1 simply does not route, which is the distinction the table exists to keep: the tree has - // the pins the BOARD has, not the pins the CHIP has. - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); - try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); - try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); -} - -test "board9p: a read of gpio/pinout is the bytes the Gpio word draws" { - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "pinout" }); - // `board_memory.zig:364`'s `pinout` IS this declaration, so this is the word's own output and - // not a copy of it. The bytes themselves are pinned by `board_pins.zig`'s golden test. - const a = try readAll(&t, at.node); - try testing.expectEqualStrings(board_pins.jp1_text, a.bytes); - // The size a client is told matches what it gets, which is what makes `cat` stop in one read. - try testing.expectEqual(board_pins.jp1_text.len, at.size); - // Read-only: the drawing is the header's, not the client's. - try testing.expectEqual(@as(u16, 0o400), at.mode); - const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); - try testing.expectEqual(E.INVAL, w.reply.errno); -} - -test "board9p: writing 1 then 0 drives the pad twice, through the seam" { - StubPads.reset(); - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "20", "value" }); - - // A fresh pad reads 0 — the level the board is DRIVING, which is defined before anybody has - // written anything. - const before = try readAll(&t, at.node); - try testing.expectEqualStrings("0\n", before.bytes); - - const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); - try testing.expectEqual(Board.Status.ok, one.reply.status); - try testing.expectEqual(@as(u32, 1), one.reply.written); - try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); - - // `echo 0 > value`, newline and all: the whole write is consumed, so the shell does not retry - // the tail and drive the pin a second time. - const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); - try testing.expectEqual(@as(u32, 2), zero.reply.written); - try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); - - // TWO CALLS, the right pin, the right levels, in order. This is the whole of what the host can - // check about the seam; that `hal.gpio` then moves the pad is the die's to answer. - try testing.expectEqual(@as(usize, 2), StubPads.log_len); - try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); - try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); -} - -test "board9p: a pad takes 0 and 1 and refuses everything else, without touching the pads" { - StubPads.reset(); - var t: Board = .{}; - const at = try walk(&t, &.{ "gpio", "45", "value" }); - - for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { - const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); - try testing.expectEqual(Board.Status.err, a.reply.status); - try testing.expectEqual(E.INVAL, a.reply.errno); - } - // A refused write is a pad that was never driven, which is the part that matters: a half-parsed - // command must not leave the board in a state nobody asked for. - try testing.expectEqual(@as(usize, 0), StubPads.log_len); - - // A register is one value, so a write at an offset is refused too, and refused before the pads. - const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); - try testing.expectEqual(E.INVAL, off.reply.errno); - try testing.expectEqual(@as(usize, 0), StubPads.log_len); -} - -test "board9p: every node's parent is the one 9p.parentOf derives, or an unallocated block" { - // THE ENCODING'S OWN TEST, and it defends the one thing this file cannot see: `src/9p.zig` - // answers `..` from the node id alone, by the rule restated at `block` above. A node numbered - // outside that rule would make `cd ..` land somewhere else with no diagnostic, so the rule is - // applied here to every generated node and compared against the table's own `parent`. - for (&Board.table) |*e| { - const serial = e.node >> 4; - const file = e.node & 0xF; - const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; - if (derived == e.parent) continue; - // The one exception, and it must be exactly the one documented: a fan leaf, whose parent is - // a block MEMBER and therefore unexpressible. Its derived parent has to be a node that does - // not exist, so the walk is refused rather than landing on the wrong file. - try testing.expectEqualStrings("value", e.name); - var t: Board = .{}; - const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); - try testing.expectEqual(E.NOENT, a.reply.errno); - } -} - -test "board9p: a directory read lists what the table generated, in table order" { - var t: Board = .{}; - - // The root is the capability list, and today that is one name. - try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); - try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); - - const gpio = (try walk(&t, &.{"gpio"})).node; - const listing = try names(&t, gpio, 0); - try testing.expectEqual(board_pins.gpio_pins.len + 1, listing.len); - try testing.expectEqualStrings("pinout", listing[0]); - for (board_pins.gpio_pins, 0..) |pin, i| { - var buf: [4]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); - } - - // The cursor is an ENTRY INDEX, which is what `Server` advances between reads of a directory - // bigger than one reply. - const rest = try names(&t, gpio, 5); - try testing.expectEqual(board_pins.gpio_pins.len + 1 - 5, rest.len); - try testing.expectEqualStrings("5", rest[0]); -} - -/// The names in one staged directory read, decoded out of `acmefs`'s `node[8] dir[1] namelen[1] -/// name[]` records — the same decode `Server` does. -var name_slots: [32][]const u8 = undefined; -fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { - const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); - try testing.expectEqual(Board.Status.ok, a.reply.status); - var n: usize = 0; - var i: usize = 0; - while (i < a.bytes.len) { - const len = a.bytes[i + 9]; - name_slots[n] = a.bytes[i + 10 ..][0..len]; - n += 1; - i += 10 + len; - } - return name_slots[0..n]; -} - -test "board9p: the whole tree costs one buffer, and the table says how big" { - // The two numbers the board's RAM budget is quoted from. `out` is the ONLY buffer this - // filesystem has, and both of its bounds come out of the table: the widest directory's staged - // entries (gpio's twelve) and the largest read scratch (a pin's two bytes). - try testing.expectEqual(@as(usize, 143), Board.out_max); - try testing.expect(@sizeOf(Board) <= 160); - // The JP1 drawing is not in it, and that is the point of `scratch = 0`: 468 bytes of static - // text are served straight out of `.rodata`. - try testing.expect(board_pins.jp1_text.len > Board.out_max); -} - -// The proof that the ABI claim in this file's header is true, and the only place the two halves meet -// on the host: `Server` is a generic over exactly `Op`, `Status`, `Req`, `Reply` and `Reply.Attr`, -// so a field that drifts from `acmefs`'s is a compile error HERE, and a real client's bytes are what -// comes out. -// -// A PATH IMPORT, and it took two goes to get here. The first was -// `@import("9p.zig")`, which did not compile while `src/9p.zig` was also the -// ROOT of a named `ninep` module in the same link — a file belongs to exactly -// one module, and it was both. The second was a named module, declared twice in -// `build.zig`; that compiled and made this file unbuildable by anyone but -// `build.zig`, which is what broke the board image the moment the firmware -// link moved to the toolchain repository and stopped injecting modules. -// -// The path form works now because nothing declares `src/9p.zig` as a module -// root any more: `fs9_service.zig` and `fs9_client.zig` reach it by path too, -// so every link that contains it contains it once. The gain is that this file -// and `src/esp32p4_9p.zig` are self-contained — `zig test src/board9p.zig` -// works with no flags, and any builder can root an image at `nine.zig` without -// being told what modules to inject. -const ninep = @import("9p.zig"); - -test "board9p: a real 9P client reads a pin's value off this tree" { - StubPads.reset(); - const Server = ninep.Server(Board); - // The board's own buffers, at the board's own msize. See `src/esp32p4_9p.zig` for why 1024. - var in: [1024]u8 = undefined; - var out: [2048]u8 = undefined; - var fsys: Board = .{}; - var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); - - var scratch: [256]u8 = undefined; - const send = struct { - fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { - const bytes = try ninep.encode(msg, tag, buf); - try testing.expectEqual(bytes.len, s.push(bytes)); - while (s.retry()) |req| { - const a = f.handle(req); - s.reply(&a.reply, a.bytes); - } - while (s.next()) |req| { - const a = f.handle(req); - s.reply(&a.reply, a.bytes); - } - } - }.call; - const reap = struct { - fn call(s: *Server) !ninep.Decoded { - const queued = s.output(); - const len = ninep.frameLen(queued) orelse return error.NoReply; - const got = try ninep.decode(queued[0..len]); - s.wrote(len); - return got; - } - }.call; - - try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); - const v = try reap(&srv); - // Clamped to what the board's buffers hold, which is the number the RAM budget was chosen for. - try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); - - try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); - try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); - - var wname: [ninep.max_welem][]const u8 = @splat(""); - wname[0] = "gpio"; - wname[1] = "20"; - wname[2] = "value"; - try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); - try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); - - try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); - _ = try reap(&srv); - - // `echo 1 > /mnt/board/gpio/20/value`, as bytes on a wire. - try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); - try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); - try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); - - // ...and `cat` of the same file. - try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); - try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); - - // The refusal reaches the client as an error STRING, which is 9P's only channel for "no": EINVAL - // becomes the wording `9p.errString` gives it, and the pad is not touched. - try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); - try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); - try testing.expectEqual(@as(usize, 1), StubPads.log_len); -} diff --git a/src/board_memory.zig b/src/board_memory.zig deleted file mode 100644 index 4bee9e6e..00000000 --- a/src/board_memory.zig +++ /dev/null @@ -1,465 +0,0 @@ -//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins' -//! whole implementation. -//! -//! THE P4 BUILD ONLY (`enabled` below), and the reason is not caution but honesty: with no OS there -//! is no MMU, no supervisor and no process - the editor IS the system software - so every one of the -//! 2^32 addresses is legitimately this program's to read and write, and a word that could name only -//! some of them would be lying about where it is running. Under an OS the same words would be either -//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present -//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose -//! platform is not `esp32p4`. -//! -//! Everything here goes through `*allowzero volatile` pointers. A peripheral -//! register is not memory: reading UART_STATUS twice is two reads and must not -//! be folded into one, a write to a write-only command register has no -//! observable value for the optimizer to keep, and address 0 is an ordinary -//! (unmapped) address on this bus rather than the null Zig assumes it is. -//! -//! The formatting side is a plain renderer over `Pardes.gpa`, so it lands in -//! an output buffer the same way Jumplist and Config do: an output buffer is a -//! file pane, so every motion, chord and Look works on a dump for free — you -//! can right-click an address in a hexdump row and Peek it. -const std = @import("std"); -const builtin = @import("builtin"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const output_pane = @import("output_pane.zig"); -const limits = @import("limits.zig"); - -/// THE ONE GATE, and it names the esp32p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed -/// and emitted for that build and for no other. Nothing in this file reaches any other target's -/// binary: not the volatile accessors, not the JP1 pinout, not the parsers. -/// -/// This used to be derived from the target - `os.tag == .freestanding and !isWasm()` - on the -/// argument that these words are a property of having no operating system rather than a product -/// configuration, and that a predicate spelled out of `builtin` cannot drift the way a -/// hand-maintained enum can. The argument was tidy and it answered the wrong question. A word -/// only exists if some SHELL offers it, and the shells are the platforms; `Gpio` settles it beyond -/// argument, because its whole content is one board's header, and a second freestanding port would -/// need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, -/// "this board" was, and the two only looked identical because there is currently one of them. -/// -/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the -/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek` -/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap -/// this same editor runs out of. Naming `esp32p4` excludes it by construction rather than by a term -/// somebody has to keep remembering. -pub const enabled = pardes.platform == .esp32p4; - -// The target is now the WITNESS rather than the gate: whatever else `esp32p4` means, it has to still be -// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the -// platform and the target disagree about what the firmware is. -comptime { - if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); - if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); - if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); -} - -/// How much of the address space ONE command may render. -/// -/// The number is set by the console, not by the memory: UART0 runs at 115200 -/// baud and measures ~11.9 KB/s on the wire, and a hexdump row is 76 bytes of -/// text per 16 bytes of memory. 4 KiB is therefore 256 rows and ~19.5 KiB of -/// text — under two seconds to paint the whole buffer, and ~4% of the 512 KiB -/// heap the firmware hands over. `Hexdump 0x0 0xffffffff` would otherwise wedge -/// the only console the board has for eleven hours, with no way to interrupt -/// it, which makes an unbounded dump not a slow command but a lost session. -/// -/// Peek's cap is the same 4 KiB window expressed in words, so `Peek a 1024` -/// and `Hexdump a 4096` cover exactly the same bytes. -pub const max_bytes: u32 = 4096; -pub const max_words: u32 = max_bytes / 4; - -/// One address past the last: the reads below are bounded by this rather than -/// wrapping, because `Hexdump 0xfffffff0 256` wrapping to 0 would silently -/// show you the bottom of the space labelled with top-of-space addresses. -const space: u64 = 1 << 32; - -pub const Error = error{ - MissingAddress, - BadAddress, - BadCount, - MissingValue, - BadValue, - /// the ONE fault this file exists to prevent by hand: the RISC-V core - /// traps an unaligned 32-bit access, and a trap in firmware with no - /// handler is a watchdog reset that takes the session with it. Reported on - /// the message row instead. - MisalignedAddress, - ExtraArgument, - /// not a number, or a number the part does not have a pad for - BadPin, - /// the host brought no pads: every build but the firmware, where the word - /// is not registered at all, and a firmware too old to pass the hook - NoPads, -}; - -/// EVERY literal these three words take is HEX, with or without an `0x`, and there is no way to -/// write a decimal one. -/// -/// This replaces base-0 parsing, which accepted `0x4ff40000` and `1341390848` and refused a bare -/// `4ff40000` on the grounds that guessing between hex and decimal would make one typo address -/// somewhere else entirely. That reasoning was sound and the conclusion was still wrong: the -/// ambiguity it protected against is not a real one. Every address anybody has ever typed at these -/// three words is hex - it came off a datasheet, a linker map, or a previous dump's own output, all -/// of which print hex - so the base was never in doubt, and demanding `0x` on every one of them was -/// a toll on the common case to guard a case that does not arise. -/// -/// The COUNTS go with them, and that is the part worth stating out loud rather than leaving as a -/// surprise: `Hexdump 4ff40000 100` shows 0x100 bytes, which is 256, not one hundred. One rule for -/// every literal in the word is worth more than two rules that each fit their argument better, -/// because the second kind is the sort of thing you have to remember at the moment you are already -/// concentrating on something else. Everything these words PRINT is hex too, including the clamp -/// notes, so a number can go back in where it came out. -fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { - // `parseInt` only honours an `0x` when its base is 0, so with base 16 the prefix has to come off - // here. A bare `0x` leaves nothing behind and `parseInt` rejects the empty string, which is the - // answer that wants giving. - const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; - return std.fmt.parseInt(T, body, 16) catch bad; -} - -fn parseAddr(tok: []const u8) Error!u32 { - return parseHex(u32, tok, Error.BadAddress); -} - -fn parseCount(tok: []const u8) Error!u64 { - return parseHex(u64, tok, Error.BadCount); -} - -fn parseValue(tok: []const u8) Error!u32 { - return parseHex(u32, tok, Error.BadValue); -} - -/// A 32-bit peripheral or RAM read that the compiler may neither elide, -/// duplicate, reorder past another access, nor narrow. -fn readWord(addr: u32) u32 { - const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); - return cell.*; -} - -fn writeWord(addr: u32, value: u32) void { - const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); - cell.* = value; -} - -fn readByte(addr: u32) u8 { - const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); - return cell.*; -} - -const Limit = enum { - /// the 4 KiB console cap above - console, - /// the end of the 32-bit address space - space, -}; - -/// How many units this command will actually show, and WHY that is fewer than -/// you asked for when it is. Never silent: the note below becomes the buffer's -/// FIRST line, which is the one place a clamp cannot be missed — a trailing -/// note on a 256-row dump is a note you scroll past. -const Extent = struct { - count: u32, - /// the tighter of the two bounds, or null when neither applied - limit: ?Limit, -}; - -fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { - var count = requested; - var limit: ?Limit = null; - if (count > cap) { - count = cap; - limit = .console; - } - const fits = (space - addr) / unit; - if (count > fits) { - count = fits; - limit = .space; - } - return .{ .count = @intCast(count), .limit = limit }; -} - -fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { - switch (e.limit orelse return) { - // Hex, like everything else these words read and print, so the number in a clamp note can go - // straight back into the command that produced it. - .console => try w.print( - "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", - .{ requested, unit_name, e.count, max_bytes }, - ), - .space => try w.print( - "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n", - .{ requested, unit_name, e.count }, - ), - } -} - -// The two bounds and their reporting, on the one part of this file that is -// pure arithmetic and therefore testable on any target — the accesses -// themselves are only meaningful on the board. -test "the clamp reports the tighter bound and never wraps the address space" { - const eq = std.testing.expectEqual; - // neither bound applied: what you asked for, and nothing to report - try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); - // the console cap, in words and in bytes - try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); - try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); - // sixteen bytes left above 0xfffffff0 — the whole point, because wrapping - // would show the BOTTOM of the space under top-of-space addresses - try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); - try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); - // ...including the row that has no whole word left in it - try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); - // both bounds at once: the tighter one is the one reported - try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); -} - -test "a clamp note is written exactly when something was clamped" { - var buf: [256]u8 = undefined; - var w: std.Io.Writer = .fixed(&buf); - - try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); - try std.testing.expectEqualStrings("", w.buffered()); - - try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); - try std.testing.expectEqualStrings( - "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n", - w.buffered(), - ); - - w = .fixed(&buf); - try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); - try std.testing.expectEqualStrings( - "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", - w.buffered(), - ); -} - -test "every literal is hex, with or without the prefix" { - const eq = std.testing.expectEqual; - // the prefix is optional, never required, and never changes the answer - try eq(0x4ff40000, parseAddr("0x4ff40000")); - try eq(0x4ff40000, parseAddr("4ff40000")); - try eq(0x4ff40000, parseAddr("0X4FF40000")); - try eq(0x4ff40000, parseAddr("4FF40000")); - // a token that looks decimal is hex too - the whole point, and the thing to remember - try eq(0x100, parseCount("100")); - try eq(0x256, parseCount("256")); - try eq(0xdeadbeef, parseValue("deadbeef")); - // and the refusals still refuse - try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); - try std.testing.expectError(Error.BadAddress, parseAddr("0x")); - try std.testing.expectError(Error.BadAddress, parseAddr("nope")); - try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); - try std.testing.expectError(Error.BadCount, parseCount("-1")); - try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); -} - -// The pinout is the one thing here whose CORRECTNESS IS ITS SHAPE: a header drawn in two columns -// stops being a header the moment a row wraps, and it wraps on the board rather than on a -// developer's terminal, which is the worst place to find out. So the width is asserted against the -// grid the board is actually built with, and the alignment is asserted against the column the pin -// numbers are supposed to share. -test "the pinout fits the board's own grid, in two aligned columns" { - const cols: usize = @import("pardes_config").esp32p4_cols; - // Seven columns of the shell's grid go to the line-number gutter before a pane's text starts. - const usable = cols - 7; - - var rows: usize = 0; - var pins: usize = 0; - var first_bar: ?usize = null; - var it = std.mem.splitScalar(u8, pinout, '\n'); - while (it.next()) |line| { - try std.testing.expect(line.len <= usable); - rows += 1; - // A pin row is one with two numbers in it; every one must put its bars in the same place, - // which is what "aligned in two columns" means when the check is mechanical. - const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; - if (line[line.len - 1] == '+') continue; - pins += 1; - if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; - } - try std.testing.expectEqual(@as(usize, 13), pins); - try std.testing.expect(rows > 15); - - // Two independent facts about the board, each with a witness outside this file: GPIO20 is - // `05-zig-p4/build.zig`'s documented `-Dled` default ("JP1 pin 17"), and pin 8 is the one - // header pin the vendor schematic leaves unconnected. - try std.testing.expect(std.mem.indexOf(u8, pinout, "GPIO 20 | 17 |") != null); - try std.testing.expect(std.mem.indexOf(u8, pinout, "| 8 | --") != null); -} - -// The exception to the file's own rule, so it is written down as a test rather than only as a -// comment: a pin number is part of a name and is read as decimal, while every address beside it is -// hex. `Gpio 20` must mean the pin the schematic calls GPIO20, not 0x20. -test "a pin number is decimal, unlike every address in this file" { - try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); - try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); - try std.testing.expect(20 != 0x20); -} - -/// `Peek [count]` — count 32-bit words at addr, one `addr: value` row -/// each. One word per row rather than four so that every row carries its own -/// address: the rows are then ordinary Look targets, and `Peek` or `Poke` -/// chorded onto one re-reads or writes exactly that word. -pub fn peek(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const requested = if (it.next()) |tok| try parseCount(tok) else 0x1; - if (it.next() != null) return Error.ExtraArgument; - if (addr % 4 != 0) return Error.MisalignedAddress; - - const e = extent(addr, requested, 4, max_words); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try writeNote(&out.writer, e, requested, "words"); - for (0..e.count) |i| { - const at = addr + @as(u32, @intCast(i * 4)); - try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) }); - } - const content = try out.toOwnedSlice(); - try fill(p, id, .{ .cmd = .Peek }, content); -} - -/// `Poke ` — one 32-bit store, then one load back, both reported -/// on the message row. -/// -/// The READ-BACK is the whole point of the word and not a confirmation: on RAM -/// it always equals what you wrote and tells you nothing, and on MMIO it -/// almost never does — a write-only command register reads as 0, a W1C status -/// bit reads back cleared, a reserved field reads back masked, and a register -/// behind a gated clock reads back whatever the bus returns for nothing at -/// all. Printing only the value written would show you your own argument. -pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const value = try parseValue(it.next() orelse return Error.MissingValue); - if (it.next() != null) return Error.ExtraArgument; - if (addr % 4 != 0) return Error.MisalignedAddress; - - writeWord(addr, value); - const back = readWord(addr); - var buf: [96]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint( - &buf, - "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", - .{ addr, value, back }, - ) catch unreachable); -} - -/// JP1, the 26-pin header down the left edge of the JC-ESP32P4-M3-DEV, as the board wears it: two -/// columns, odd pins on the left, even on the right, pin 1 at the top. -/// -/// MOVED TO `src/board_pins.zig`, where the thirteen rows are DATA and this drawing is rendered -/// from them at comptime. Not for tidiness: the board's 9P image (`src/esp32p4_9p.zig`) links no -/// core, so it cannot import this file — this one imports `pardes.zig` — and that image serves this -/// exact drawing as `gpio/pinout` while generating its per-pin directories from the same rows. The -/// alternative was transcribing a schematic twice, which is two things to maintain and no test that -/// could say which one was wrong. The provenance moved with the rows: which sheet of which -/// schematic, how pin 8 was identified as unconnected, and what `--`, `C6_*` and `ES_I2C_*` mean. -/// -/// The test below is unchanged, and it is still the check that matters HERE: whoever renders this -/// drawing, the `Gpio` word's output has to fit the board's own grid in two aligned columns. -const pinout = @import("board_pins.zig").jp1_text; - -/// `Gpio ` flips one pad and says what it did; `Gpio` alone draws JP1. -/// -/// THE PIN NUMBER IS DECIMAL, and it is the one literal in this file that is. Every other one is -/// hex because every other one is an address, and addresses come off datasheets and linker maps -/// that print hex. A GPIO number is not an address - it is part of a NAME. The schematic says -/// `GPIO47`, the silkscreen says 47, the datasheet's pin table says 47, and `Gpio 20` meaning pin -/// 32 would be a trap laid for the one argument a person types from memory. One rule per KIND of -/// literal beats one rule per file when the kinds are this different. -/// -/// The toggle is the host's to perform (`Host.VTable.pull_gpio_toggle`) even though `Poke` two -/// functions up would happily write GPIO_OUT_REG directly. Writing that register is not the job: -/// a pad has to be pointed at the GPIO peripheral in the IO MUX, routed in the GPIO matrix, have -/// its driver and input buffer enabled, and only then be driven - and getting that wrong on a pin -/// that boots as something else is how you lose the console you are typing on. -/// -/// Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the -/// level this board is DRIVING. A pad's input buffer on an unconnected header pin reads whatever -/// the air says. -pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const tok = it.next() orelse { - // No argument is not an error and not inert: it is the question "which pins are there", - // and the answer is a picture of the header. - const content = try p.gpa.dupe(u8, pinout); - errdefer p.gpa.free(content); - return fill(p, id, .{ .cmd = .Gpio }, content); - }; - if (it.next() != null) return Error.ExtraArgument; - const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; - - const toggle = p.host.vtable.pull_gpio_toggle orelse return Error.NoPads; - var was: u8 = 0; - var now: u8 = 0; - if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; - - var buf: [48]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); -} - -/// Bytes per dumped row, and it is a different number on the board — see -/// `limits.hexdump_row_bytes`, which is where that number and its reasoning -/// live now. -const row_bytes: u32 = limits.hexdump_row_bytes; - -/// `Hexdump [len]` — len bytes, `row_bytes` to a row, hex columns and an ASCII gutter, in -/// `hexdump -C`'s layout because that is the one everyone can already read. BYTE reads, so a partial -/// row at the end of the space is a short row rather than a refusal, and no alignment is required: -/// this is the word you reach for when you do not yet know what is there. -pub fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { - var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); - const addr = try parseAddr(it.next() orelse return Error.MissingAddress); - const requested = if (it.next()) |tok| try parseCount(tok) else 0x100; - if (it.next() != null) return Error.ExtraArgument; - - const e = extent(addr, requested, 1, max_bytes); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try writeNote(&out.writer, e, requested, "bytes"); - var row: u32 = 0; - while (row < e.count) : (row += row_bytes) { - const n = @min(row_bytes, e.count - row); - var bytes: [row_bytes]u8 = undefined; - for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); - try out.writer.print("{x:0>8} ", .{addr + row}); - for (0..row_bytes) |i| { - // The gap at the halfway mark: the eye counts to four or eight, not to sixteen. - if (i == row_bytes / 2) try out.writer.writeByte(' '); - if (i < n) - try out.writer.print(" {x:0>2}", .{bytes[i]}) - else - try out.writer.writeAll(" "); - } - try out.writer.writeAll(" |"); - for (0..n) |i| try out.writer.writeByte( - if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', - ); - try out.writer.writeAll("|\n"); - } - const content = try out.toOwnedSlice(); - try fill(p, id, .{ .cmd = .Hexdump }, content); -} - -/// The shared tail. `fillResults` is the one public entry that REFILLS the -/// buffer a command already opened instead of stacking a twin beside it, which -/// is what a dump wants: peeking twenty addresses in a row is twenty renders -/// of one window on memory, not twenty panes. The empty argument is what makes -/// it one window — a dump is identified by the command, never by the address, -/// so a second Peek replaces the first rather than opening a buffer per -/// address and exhausting the pane slots. -/// -/// Neither buffer `steps`, so nothing is armed on n/N and focus stays in the -/// pane you typed the command in. `content` is gpa-owned and adopted there. -fn fill(p: *Pardes, id: usize, from: output_pane.Origin, content: []u8) !void { - const pane = p.panes[id] orelse { - p.gpa.free(content); - return error.MissingPane; - }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - try output_pane.fillResults(p, id, dir, from, "", content, null); -} diff --git a/src/board_pins.zig b/src/board_pins.zig deleted file mode 100644 index 134cd92c..00000000 --- a/src/board_pins.zig +++ /dev/null @@ -1,186 +0,0 @@ -//! JP1, the JC-ESP32P4-M3-DEV's 26-pin header, as ONE TABLE that everything else is derived from: -//! the ASCII drawing the `Gpio` word prints, and the pin directories the board's 9P tree generates. -//! -//! WHY THIS IS ITS OWN FILE, and it is the whole reason it exists. The drawing lived in -//! `src/board_memory.zig`, which imports `pardes.zig` and therefore the entire core; the board's 9P -//! image (`src/esp32p4_9p.zig`) links no core at all, so it could not have reached it. The two -//! ways out of that were a second copy of the header in the 9P tree — a table of thirteen rows -//! transcribed off a schematic, maintained twice, with no test that could tell you the day they -//! disagreed — or this: a LEAF that imports `std` and nothing else, so both sides import the same -//! thirteen rows. `board_memory.zig` keeps its `pinout` name as an alias of `jp1_text` and its own -//! shape test, so the console word's output is unchanged to the byte. -//! -//! WHY A TABLE AND NOT THE STRING. The string was the source before, and a string is fine for one -//! consumer that prints it. It is no use at all to the second, which needs to know WHICH of these -//! twenty-six pins are the P4's own GPIOs, because that is the set of directories its tree has. A -//! consumer would have to parse the drawing back out — scan for `GPIO `, take the digits, hope -//! nobody aligned a column differently — which is exactly the sort of code that works until the -//! day the drawing is edited. So the rows are data, the drawing is RENDERED from them at comptime, -//! and `gpio_pins` is COLLECTED from them at comptime. Adding a pin to the header is one row, and -//! the drawing, the pin list and the 9P tree all move together because there is only one of them. -//! -//! READ OFF THE VENDOR SCHEMATIC, sheet 2 "Expand IO" -//! (`01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png`), which is the only document that carries -//! this mapping — the specification PDF's "Interface Description" page is a marketing render, and -//! there is no board user guide. The sheet is a 872x1168 raster, so the assignment was taken from -//! the drawing's own geometry rather than by eye: thirteen wires leave each side of the symbol, a -//! net wire runs ~100 px to its label and a power stub ~21 px, which is what identifies pin 8 as -//! unconnected rather than as the first of the GPIO4x labels. Cross-checked against a second, -//! independent source: `05-zig-p4/build.zig` has always documented `-Dled=20` as "JP1 pin 17", and -//! GPIO20 lands on pin 17 here. -const std = @import("std"); - -/// What is behind one header pin, and the ONE distinction that matters to both consumers: whether -/// this pad is a GPIO of the ESP32-P4 this program is running on. -/// -/// `.none` is a pin the header brings out with nothing behind it (pin 8). `.net` is a pad that is -/// not the P4's to drive as a GPIO: `3V3`, `5V` and `GND` are power, `C6_*` are the ESP32-C6 -/// companion's pins — toggling a P4 GPIO cannot reach them — and `ES_I2C_*` is the audio codec's -/// bus. The codec's two ARE P4 pads, and they are `.net` anyway, deliberately: the schematic does -/// not name their GPIO numbers, and a tree that invented one would offer a file that drives an -/// unknown pin. They stay in the drawing because a shared bus is a reason to know the pin is there. -pub const Pad = union(enum) { - none, - /// a P4 GPIO, by the number the schematic, the silkscreen and the datasheet all use - gpio: u8, - /// a named net that is not a P4 GPIO - net: []const u8, - - /// The text this pad wears in the drawing. `GPIO 47` and not `GPIO47`: the space is what the - /// header has always printed, and the shape test in `board_memory.zig` matches on it. - pub fn label(p: Pad) []const u8 { - return switch (p) { - .none => "--", - .gpio => |n| std.fmt.comptimePrint("GPIO {d}", .{n}), - .net => |s| s, - }; - } -}; - -/// One row of the header: the odd pin on the left, the even pin on its right, exactly as the board -/// wears it. The pin NUMBERS are not stored — row `i` is pins `2i+1` and `2i+2` — because a -/// hand-written number beside a row is a number that can disagree with its position. -pub const Row = struct { left: Pad, right: Pad }; - -/// JP1 itself: thirteen rows, pin 1 at the top left. THE SINGLE SOURCE for the drawing below, for -/// `gpio_pins`, and for the per-pin directories in `src/board9p.zig`. -pub const jp1 = [13]Row{ - .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, - .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, - .{ .left = .{ .net = "GND" }, .right = .{ .net = "GND" } }, - .{ .left = .{ .gpio = 1 }, .right = .none }, - .{ .left = .{ .gpio = 2 }, .right = .{ .gpio = 47 } }, - .{ .left = .{ .gpio = 3 }, .right = .{ .gpio = 46 } }, - .{ .left = .{ .gpio = 4 }, .right = .{ .gpio = 45 } }, - .{ .left = .{ .gpio = 5 }, .right = .{ .net = "GND" } }, - .{ .left = .{ .gpio = 20 }, .right = .{ .net = "3V3" } }, - .{ .left = .{ .gpio = 32 }, .right = .{ .net = "C6_U0RXD" } }, - .{ .left = .{ .gpio = 33 }, .right = .{ .net = "C6_U0TXD" } }, - .{ .left = .{ .net = "ES_I2C_SDA" }, .right = .{ .net = "C6_IO9" } }, - .{ .left = .{ .net = "ES_I2C_SCL" }, .right = .{ .net = "C6_CHIP_PU" } }, -}; - -/// The row format, and it is load-bearing rather than cosmetic: a header drawn in two columns stops -/// being a header the moment a row wraps or a column slips, and the widest row here is 34 columns -/// against the board's own 80-column grid. Ten for the left label right-aligned, two for each pin -/// number, and the three bars land under the box's own corners because the left label's field plus -/// one space is eleven characters and `+---------+` is eleven wide. -/// -/// `board_memory.zig`'s "the pinout fits the board's own grid" test is the check that this stays -/// true, and it checks the RENDERED text mechanically — every pin row's first bar in the same -/// column — rather than trusting this string. -const row_format = "{s:>10} | {d:>2} | {d:>2} | {s}\n"; - -/// The box the pin numbers sit inside. Eleven characters, indented by the left label's field width -/// plus the space before the first bar, so its corners are the bars. -const border = " +---------+\n"; - -/// JP1 as the text the `Gpio` word prints and a read of the 9P tree's `gpio/pinout` returns — the -/// SAME BYTES, which is a test in `src/board9p.zig` and not a hope. -/// -/// The trailer names the `Gpio` word, which the 9P image does not have. It is here anyway, because -/// "the same bytes" is worth more than a sentence that is true of both faces and useful to neither: -/// a person reading this table through 9P is a person who has the editor's own console in the other -/// window, and telling them the word that flips a pin is telling them something they can use. The -/// 9P equivalent — writing `0` or `1` to `gpio//value` — is documented where a 9P client will -/// look for it, which is the tree's own doc comment. -pub const jp1_text = text: { - var out: []const u8 = - \\JP1 header - 26 pins, pin 1 top left. - \\Every number here is DECIMAL. - \\ - \\ - ; - out = out ++ border; - for (jp1, 0..) |row, i| out = out ++ std.fmt.comptimePrint( - row_format, - .{ row.left.label(), 2 * i + 1, 2 * i + 2, row.right.label() }, - ); - break :text out ++ border ++ - \\ - \\Gpio flips one: 0->1 or 1->0. - \\ - ; -}; - -/// Every P4 GPIO JP1 brings out, ascending. THE SET OF PIN DIRECTORIES the board's 9P tree has, so -/// that tree has exactly the pins this board has and not a range somebody typed. -/// -/// Ascending rather than in header order, because the consumer is `ls`: the header's order puts 47 -/// between 2 and 3, and a directory listing that counts 1 2 3 4 5 20 32 33 45 46 47 is one a person -/// can scan. Nothing depends on the order — the names are the pin numbers — so it may as well be -/// the readable one. -pub const gpio_pins = pins: { - var found: [2 * jp1.len]u8 = undefined; - var n: usize = 0; - for (jp1) |row| for ([2]Pad{ row.left, row.right }) |p| switch (p) { - .gpio => |g| { - found[n] = g; - n += 1; - }, - else => {}, - }; - std.mem.sort(u8, found[0..n], {}, std.sort.asc(u8)); - break :pins found[0..n].*; -}; - -// The drawing, byte for byte, because it is the one thing here whose CORRECTNESS IS ITS SHAPE and -// because it used to be a string literal: this is the check that the renderer above reproduces what -// the console has always printed. A golden test is the right kind of duplication — the expectation -// is the thing being asserted, and if the two ever differ the diff says which byte. -test "the rendered header is the drawing the console has always printed" { - try std.testing.expectEqualStrings( - \\JP1 header - 26 pins, pin 1 top left. - \\Every number here is DECIMAL. - \\ - \\ +---------+ - \\ 3V3 | 1 | 2 | 5V - \\ 3V3 | 3 | 4 | 5V - \\ GND | 5 | 6 | GND - \\ GPIO 1 | 7 | 8 | -- - \\ GPIO 2 | 9 | 10 | GPIO 47 - \\ GPIO 3 | 11 | 12 | GPIO 46 - \\ GPIO 4 | 13 | 14 | GPIO 45 - \\ GPIO 5 | 15 | 16 | GND - \\ GPIO 20 | 17 | 18 | 3V3 - \\ GPIO 32 | 19 | 20 | C6_U0RXD - \\ GPIO 33 | 21 | 22 | C6_U0TXD - \\ES_I2C_SDA | 23 | 24 | C6_IO9 - \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU - \\ +---------+ - \\ - \\Gpio flips one: 0->1 or 1->0. - \\ - , jp1_text); -} - -// The pin list is the tree's shape, so it is asserted as a list rather than as a count: a row edited -// wrongly changes WHICH pins the board offers, and a count would not notice a 45 that became a 44. -test "the header's own GPIOs, and only those" { - try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 5, 20, 32, 33, 45, 46, 47 }, &gpio_pins); - // Pin 8 is unconnected and pin 24 is the C6's, so neither contributes a pad. Both are counted - // here rather than only drawn, because "the tree has exactly the pins the board has" is a claim - // about what is ABSENT as much as what is present. - try std.testing.expectEqual(Pad.none, jp1[3].right); - try std.testing.expectEqualStrings("C6_IO9", jp1[11].right.net); -} diff --git a/src/builtins.zig b/src/builtins.zig index f7822caf..e55a418f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1,48 +1,17 @@ -//! The builtins: one struct each, plus setting commands generated from one -//! runtime_config table. -//! -//! Executing a builtin's NAME (middle-click / Tab) runs it through the one -//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The -//! struct's DECL NAME is the user-visible word — the one in the topbar, the -//! one sitting in a tag, the one Help prints, the one you execute — so -//! `std.meta.stringToEnum` is the lookup and there is no name table to sync. -//! -//! A zig file IS a struct, so THIS FILE'S declarations are the manual list: -//! the registry walks them at comptime and appends the enabled settings from -//! runtime_config.settings. There is no hand-maintained enum or dispatcher -//! switch to keep in sync; declarations and setting descriptors are the data. -//! -//! A manual builtin is a struct declaring `pub fn run(Ctx) void`; an optional -//! explicit `enabled` declaration gates it. Helpers have no `run`, and a -//! claimed builtin with the wrong signature is a compile error. -//! -//! What is NOT here: the key bindings. `leader_path` is ONE table in -//! config.zig next to `topbar_str` and every other syntactic choice — the -//! whole remapping surface belongs in one file a user can read top to bottom, -//! not scattered a line at a time across thirty-three structs. +//! Command structs and runtime settings form the builtin registry; bindings live in config.zig. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; -const output_pane = @import("output_pane.zig"); -const image_pane = @import("image_pane.zig"); +const panes = @import("panes.zig"); +const layout = @import("layout.zig"); const config = @import("config.zig"); -const runtime_config = @import("runtime_config.zig"); -const board_memory = @import("board_memory.zig"); -/// The host half of the 9P client, for the `9p` word at the bottom. Imported -/// unconditionally and gated on `fs9_client.supported`, exactly like -/// board_memory above: nothing in it is analysed for a build whose platform -/// has no unix sockets, because the word is not registered there at all. -const fs9_client = @import("fs9_client.zig"); - -/// The platform's runtime-setting facilities, stated once as plain data. -/// Registry generation, leader paths, Config, and EffectCode all consume this -/// exact value rather than rebuilding equivalent-looking boolean expressions. -pub const capabilities: runtime_config.Capabilities = .{ +const builtin = @import("builtin"); +const Header = @import("esp32p4_gpio.zig").Header; +const limits = @import("memory.zig").limits; + +pub const capabilities: config.Runtime.Capabilities = .{ .font_picker = pardes.font_picker, - // A transition is composited by the shell, and EffectCode has to be able - // to show WHICH compositor: only the three hosted shells are in this - // package, so the hostless platforms have no honest source to print. .panel_transitions = pardes.hosted, .scene_shaders = pardes.platform == .gui or pardes.platform == .macos, // The tty's font belongs to its emulator, and the P4 firmware's belongs to @@ -50,26 +19,14 @@ pub const capabilities: runtime_config.Capabilities = .{ .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4, }; -/// What a builtin gets to act on. One bundle rather than five parameters -/// because most builtins want two of them and zig rejects the unused rest. -/// `txt` is the executed text (Restore reads its path back out of it) and -/// `arg` the execute's ARGUMENT — text typed after the name, or the selection -/// a mouse chord kept, which is why Grep and Find run straight away when there -/// is one instead of asking. The leader passes "" and null: a key path names a -/// builtin, never an argument. pub const Ctx = struct { p: *Pardes, - /// pane `id`, already resolved — the dispatcher's null check is the one - /// guard every builtin used to share. pane: *Pane, id: usize, txt: []const u8, arg: ?[]const u8, }; -/// Every enabled builtin, in source order. Feature gates are explicit data; -/// a declaration that claims to be enabled but has the wrong run signature is -/// a compile error instead of silently disappearing from the command enum. fn isEnabled(comptime T: type) bool { return !@hasDecl(T, "enabled") or T.enabled; } @@ -108,18 +65,18 @@ fn manualBuiltinList() [manualBuiltinCount()]type { fn settingCount() comptime_int { comptime { var count = 0; - for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) { + for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { count += 1; }; return count; } } -fn settingList() [settingCount()]runtime_config.Setting { +fn settingList() [settingCount()]config.Runtime.Setting { comptime { - var list: [settingCount()]runtime_config.Setting = undefined; + var list: [settingCount()]config.Runtime.Setting = undefined; var count = 0; - for (runtime_config.settings) |setting| if (setting.enabled(capabilities)) { + for (config.Runtime.settings) |setting| if (setting.enabled(capabilities)) { list[count] = setting; count += 1; }; @@ -127,11 +84,6 @@ fn settingList() [settingCount()]runtime_config.Setting { } } -/// A builtin's user-visible word: the LAST dotted segment of `@typeName`, -/// because @typeName spells a file-scope struct fully qualified -/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct, -/// which would be the same word written twice with nothing keeping the two -/// honest. A name that is not a legal identifier would be spelled `@"..."`. pub fn word(comptime T: type) []const u8 { const n = @typeName(T); const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n; @@ -144,28 +96,14 @@ pub const OutputTraits = struct { jumps: bool = false, commands: bool = false, doc: bool = false, - /// the buffer BECOMES an ordinary file once written (the New scratch, and a - /// real file, which is one already). Every other output buffer is a - /// RENDERING: Save writes its text out and the buffer stays what it is, - /// refillable and steppable, because a saved copy of a search is a copy of - /// a search and not the search. + // Saving promotes this scratch buffer into an ordinary file. saves: bool = false, }; -/// The enum: field name = struct name, value = index into `all()`. Everything -/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime -/// check, stringToEnum) speaks it exactly as it did when it was hand-written. -/// -/// Registry-dependent APIs live in one namespace so the outer declaration -/// walk only sees this namespace's type, not functions whose signatures depend -/// on the builtin enum being constructed. +// Keep enum-dependent signatures out of the outer declaration walk. pub const registry = struct { pub fn Builtin() type { - // The duplicate-name check below is O(n^2) string comparisons over every manual builtin AND - // every generated setting, so this quota grows quadratically with the builtin count. 20,000 - // was enough until three more (Peek/Poke/Hexdump) tipped `-Dplatform=gui` over with - // "evaluation exceeded 20000 backwards branches". Raised with room rather than to the next - // value that happens to pass, so the next builtin does not have to rediscover this. + // Duplicate-name validation compares every pair. @setEvalBranchQuota(200_000); const manual = manualBuiltinList(); const generated = settingList(); @@ -206,7 +144,7 @@ pub const registry = struct { test "capabilities exactly gate setting and effect-source builtins" { const Builtin = registry.Builtin(); - for (runtime_config.settings) |setting| { + for (config.Runtime.settings) |setting| { const registered = std.meta.stringToEnum(Builtin, setting.word) != null; try std.testing.expectEqual(setting.enabled(capabilities), registered); } @@ -214,34 +152,15 @@ test "capabilities exactly gate setting and effect-source builtins" { try std.testing.expectEqual(EffectCode.enabled, effect_code_registered); } -// The three memory words, checked the same way but at COMPTIME rather than in -// a test, because the property is about builds this test binary is not: the -// tty suite can only ever observe its own platform, and what matters is that -// `-Dplatform=web -Dtarget=wasm32-freestanding` does not quietly hand a -// browser tab a Poke. Every build of every platform now proves its own half. comptime { for ([_][]const u8{ "Peek", "Poke", "Hexdump" }) |name| - if (@hasField(registry.Builtin(), name) != board_memory.enabled) @compileError( + if (@hasField(registry.Builtin(), name) != Board.enabled) @compileError( "bare-metal memory word gating leaked: " ++ name, ); } // ---- the two acme verbs ---- -// Look and Execute are the verbs the whole environment is built on, and they -// are BUILTINS: `Look main.zig` typed in a tag and executed is the same look a -// right click on `main.zig` is, `Exec ls` the same as a middle click on `ls`. -// The mouse buttons and Enter/Tab are not a second path into them any more — -// they are two bindings pointing here (config.look_cmd / exec_cmd), the status -// `SPC f s` has relative to Save. That is the whole feature: what used to be a -// `button` parameter threaded through every keyboard call site, with the -// builtin dispatch nested INSIDE it, is now one word each. -// -// The operand is `arg` in both — a name's tail (`Look main.zig`), else the -// selection a chord kept, else the word the gesture pointed at, which the -// gesture resolves and passes. Nothing to act on means nothing happens, the -// way `Save` on a terminal is inert. - pub const Look = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { @@ -276,38 +195,19 @@ pub const Dump = struct { pub const Restore = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { - var it = std.mem.tokenizeAny(u8, c.txt, " \t"); - _ = it.next(); // the word "Restore" - const path = it.next() orelse (c.p.last_dump orelse return); + const path = c.arg orelse (c.p.last_dump orelse return); if (path.len > c.p.restore_buf.len) return; @memcpy(c.p.restore_buf[0..path.len], path); c.p.restore_req = c.p.restore_buf[0..path.len]; } }; -/// `Attach [name]` — hand this frontend's screen to a detached core, the one -/// `pardes --detach [name]` left running. Bare, it means "the session that is -/// there", which is the case worth typing: one detached session, and one word -/// to walk back into it. -/// -/// Nothing is torn down HERE, and that is the feature rather than an omission. -/// The effect only ASKS; the shell connects first and swaps second, so an -/// Attach that reaches nothing leaves this instance with every pane and every -/// undo exactly where they were and a line on the message row. Absent where -/// there is no unix socket to attach to — the browser and the board — and also -/// absent where the frontend would never NOTICE the request: see -/// `pardes.can_attach`, which is narrower than `hosted` because macOS never -/// polls `takeAttach`, so the word would have queued an effect and then done -/// nothing at all. pub const Attach = struct { pub const takes_arg = true; pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { if (comptime enabled) ask(c) else unreachable; } - /// A name too long for `Effect.attach` is too long for `sun_path` several - /// times over, so it can never name a session: reporting it here is the - /// same answer a failed connect gets, one round trip earlier. fn ask(c: Ctx) void { const name = c.arg orelse ""; if (name.len > pardes.attach_name_max) @@ -316,17 +216,6 @@ pub const Attach = struct { } }; -/// `Detach` — leave the session and let it carry on without you, which is -/// tmux's detach-client. Executed inside an ATTACHED frontend, where it -/// travels to the daemon as an ordinary command line, is run by the core that -/// owns the panes, and comes back as the effect that dismisses the screen -/// which asked for it. Hence no argument: the daemon knows who typed. -/// -/// It is NOT `Attach` backwards, and no word here is. Making a live local -/// session outlive its terminal means setsid and a fork; a word that pretended -/// to would hand you a session that dies with the window it was typed in. Run -/// locally this therefore REPORTS rather than acts — see the `.detach` arm of -/// Pardes.perform, which finds no host method to call. pub const Detach = struct { pub const enabled = pardes.can_attach; pub fn run(c: Ctx) void { @@ -337,26 +226,33 @@ pub const Detach = struct { } }; +pub const Mount = struct { + pub const takes_arg = true; + pub const enabled = pardes.hosted; + pub fn run(c: Ctx) void { + if (comptime !enabled) unreachable; + var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); + const name = args.next() orelse return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); + const dial = std.mem.trim(u8, args.rest(), " \t"); + if (dial.len == 0) return c.p.reportError(c.id, "Mount name dial", error.MissingArgument); + c.p.fs.mount(c.p.gpa, name, dial) catch |err| return c.p.reportError(c.id, "Mount", err); + } +}; + +pub const Unmount = struct { + pub const takes_arg = true; + pub const enabled = pardes.hosted; + pub fn run(c: Ctx) void { + if (comptime !enabled) unreachable; + var args = std.mem.tokenizeAny(u8, c.arg orelse "", " \t"); + const name = args.next() orelse return c.p.reportError(c.id, "Unmount name", error.MissingArgument); + if (args.next() != null) return c.p.reportError(c.id, "Unmount name", error.TooManyArguments); + pardes.filesystem.unmount(c.p, name) catch |err| return c.p.reportError(c.id, "Unmount", err); + } +}; + // ---- the message row ---- -/// TEXT onto this pane's transient message row — the row a failed save, a -/// refused Look and a language server that would not start all report through -/// (Pardes.setMessage, and Pardes.reportError one line above it). Every writer -/// of that row is something going wrong, so until this word there was no way -/// to look at it without breaking something on purpose: no wording could be -/// checked against a narrow pane, and no test could pin the row without -/// arranging a real failure first. -/// -/// Bare, it reports ITSELF through the error path, because that is the other -/// half of the same machinery — `reportError` is `setMessage` plus an -/// `: ` — and because a word that needs no argument to -/// demonstrate one is a word you can also just click. -/// -/// Whether the row is FREE is not asked here and is not this word's business: -/// an armed prompt outranks a message at render time, so posting under one is -/// stored and invisible, exactly as a save finishing under one is. Nor does -/// anything here decide when it goes away — your next key or click does, on -/// every pane at once, because a message is exactly as old as your last input. pub const Msg = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { @@ -367,53 +263,19 @@ pub const Msg = struct { } }; -// ---- display choices ---- -// -// The plain toggles, named theme/shell/font setters and animation effects are -// generated from runtime_config.settings. Keeping their command metadata and -// their query order in the same value table is what prevents a settable choice -// from disappearing from Config. Hand-written commands continue below. - -/// One step along the ring. With 228 themes in it this is no longer a way to -/// REACH a theme — ThemeSel is — but it is still the way to browse one, and the -/// browse got better rather than worse: the generated half is sorted by name, so -/// the neighbours of wherever you are are that theme's own variants (light, -/// hard, soft, the whole gruvbox family in a row). Kept as the topbar word and -/// SPC t n it has always been; a ring you can walk off the end of in three -/// clicks was never what made it useful. pub const NextColor = struct { pub fn run(c: Ctx) void { c.p.setThemeIndex((@as(usize, c.p.settings.theme) + 1) % pardes.themes.len); } }; -/// The theme BY NAME — `Theme acme`. The ring grew past the point where -/// cycling to the one you want is reasonable, so this is the way to ask for -/// one, and NextColor stays as the way to browse. Inert without an argument -/// (there is no theme called nothing), which is also why it has no leader path: -/// a key path names a builtin and can never carry the name of a theme. -/// -/// A LINEAR SCAN over 228 names, on a keystroke: the alternative is a comptime -/// name->index map, which is a second copy of the ring to build for a lookup -/// nobody will ever measure. 228 short string compares is microseconds, and it -/// happens once per theme change, not once per frame. -/// ...and the list of what Theme takes, as a buffer you walk. Its rows are -/// `Theme ` COMMANDS rather than locations, which is one flag on the -/// buffer (output_pane.Traits.commands) and changes what a step SELECTS: the -/// whole line, since there is no path inside it to pick out. Tab on what n -/// selected wears that theme — the same middle click on the row is — so -/// walking the list with n/Tab is trying them on, and stopping is choosing. pub const ThemeSel = struct { pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true }; pub fn run(c: Ctx) void { - output_pane.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); + panes.Output.openThemes(c.p, c.id) catch |err| c.p.reportError(c.id, "themes", err); } }; -/// Load one complete Theme value from a .zon file. Relative paths are rooted -/// at the per-user pardes directory, so an init line can simply say -/// `ThemeFile themes/mine.zon`. The native host owns the read and watch; the -/// core owns parsing and keeps the last valid value across a bad live edit. pub const ThemeFile = struct { pub const takes_arg = true; pub const enabled = pardes.hosted; @@ -425,9 +287,6 @@ pub const ThemeFile = struct { } }; -/// Materialize every compiled theme as editable ZON under -/// `/themes/builtin`. Filesystem work remains a host effect, just like -/// Dump and Save; the build-time ring itself is the sole source of the data. pub const DumpThemes = struct { pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { @@ -441,33 +300,6 @@ pub const DumpThemes = struct { } }; -// ---- the GUI's font list, and NOTHING on any other platform ---- -// -// Runtime settings such as Font are generated from runtime_config.settings. -// FontSel remains hand-written because it opens a result pane. Its explicit -// `enabled` bit is the same feature gate the registry uses for PDF commands: -// disabled commands have no enum field, help row, or dispatcher case. - -/// The GUI font BY NAME — `Font DejaVuSansMono-Regular`, the way `Theme ` -/// takes a theme, and inert without an argument for the same reason (there is -/// no font called nothing). The name is a font FILE's stem, which is what the -/// picker lists; resolving it is a walk of the font directories that stops at -/// the first match, so nothing is cached and an install five seconds ago is -/// findable. -/// -/// The core cannot load a font — it has no rasterizer, no atlas and no window -/// — so this asks: the resolved PATH goes in runtime config, the shell takes it on -/// its next pass and re-rasters. Exactly the shape Restore already has. -/// ...and the list of what Font takes: every MONOSPACE font on the machine, -/// one `Font ` row each, in the picker ThemeSel already is (rows that -/// are commands, so n/N select each one WHOLE and Tab runs it — walking with -/// n and pressing Tab wears each font in turn, and picking one is stopping -/// there). -/// -/// Monospace only, which is the one judgement in the feature: the grid is a -/// fixed cell, so a proportional face is not a worse-looking option but an -/// unreadable one — and this picker EXECUTES what it steps onto, so listing -/// them would mean the list wearing one on the way past. See fonts.monospaced. pub const FontSel = struct { pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true }; pub const enabled = capabilities.font_picker; @@ -475,39 +307,30 @@ pub const FontSel = struct { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - output_pane.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err); + panes.Output.openFonts(c.p, c.id) catch |err| c.p.reportError(c.id, "fonts", err); } }; -/// Toggle a native PDF between the reading-oriented fit-width view and the -/// whole-page-height view. The explicit feature gate omits the command, help -/// row, and dispatcher case when MuPDF is disabled. pub const PdfFit = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.toggleFit(c.pane); + panes.Pdf.toggleFit(c.pane); } }; -/// Cycle a native PDF through original pixels, a chroma-preserving themed -/// filter, and a full theme duotone. It has the same explicit feature gate as -/// PdfFit: absent without MuPDF and inert off a PDF pane. pub const PdfTint = struct { pub const enabled = pardes.pdf_enabled; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.toggleTint(c.pane); + panes.Pdf.toggleTint(c.pane); } }; -/// Show this PDF's document outline as a live, steppable output pane. The -/// command is absent from non-MuPDF builds and deliberately inert on every -/// other pane kind, like the two PDF display toggles above. pub const PdfSections = struct { pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true }; pub const enabled = pardes.pdf_enabled; @@ -515,60 +338,33 @@ pub const PdfSections = struct { if (comptime enabled) apply(c) else unreachable; } fn apply(c: Ctx) void { - pardes.pdf_pane.openSections(c.p, c.id); + panes.Pdf.openSections(c.p, c.id); } }; -// The image pane's three renderer toggles. They used to be executable words -// interpreted by a special tag dispatcher; as ordinary builtins they are -// pressable under SPC and listed by `SPC ?`. The tag now reports their plain -// live values without becoming a second mutation path. Each acts on the pane -// it runs in and is inert elsewhere, the way Save is on a terminal. - /// glyph art over the host's pixels pub const Petscii = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.togglePetscii(state); + if (c.pane.image) |*state| panes.Image.toggleGlyphArt(state); } }; /// the C64 palette or the terminal's own 16 pub const Palette = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.togglePalette(state); + if (c.pane.image) |*state| panes.Image.togglePalette(state); } }; /// add the printable ASCII bitmaps to the matcher's glyph set pub const Ascii = struct { pub fn run(c: Ctx) void { - if (c.pane.image) |*state| image_pane.toggleAscii(state); + if (c.pane.image) |*state| panes.Image.toggleAscii(state); } }; // ---- the system clipboard ---- -// helix's `` clipboard menu, and the ONLY five words in pardes that -// touch the desktop's clipboard. Everything else — `y`, `d`, `c`, `p`, `P`, -// `R`, the acme cut/paste chords — lives entirely in the internal register, -// which is helix's arrangement and, less abstractly, the reason deleting a -// character no longer throws away whatever you had copied from a browser. -// -// They are builtins rather than bare chords because the leader table is the -// remapping surface and a leader path names a builtin: spelling them here -// puts them in Help's index, makes them executable words like every other -// verb, and costs no second mechanism. Their paths ARE helix's letters, on -// the same leader helix uses — see config.leader_path. -// -// The two directions are not symmetric, and cannot be. Writing is a fire-off: -// the core owns the bytes and the shell copies them out. READING has to leave -// the core and come back — SDL and NSPasteboard answer inside the same drain, -// a browser answers a promise later, and a terminal answers over OSC 52 or, -// far more often, refuses outright. So a paste is a REQUEST (the -// read_clipboard effect) that may simply never be answered, and a `SPC p` -// that does nothing in a locked-down terminal is the honest outcome rather -// than a bug to paper over with the internal register. - pub const ClipYank = struct { pub fn run(c: Ctx) void { c.p.clipYank(c.pane, false); @@ -603,33 +399,16 @@ pub const ClipReplace = struct { // ---- panes and columns ---- -/// Write this pane's text out. A pane with a real file behind it writes THAT -/// file with no argument — acme's Put, what `:w` has always meant — and -/// that is the only pane Save can serve without being told where. -/// -/// Everywhere else the path is REQUIRED, so a bare `Save` asks for one exactly -/// the way Find and Grep ask for a pattern: the tag input arms prefilled with -/// the pane's directory and Enter commits it. A terminal writes its plaintext -/// scrollback and stays a terminal; an output buffer writes its rows and stays -/// an output buffer, still refillable and still walked by n/N — with the one -/// exception the New scratch has always been, an empty buffer whose whole -/// purpose is to become the file you name (output traits: `saves`). -/// -/// Images and PDFs hold nothing of their own that is unwritten, so the word is -/// inert there and absent from their tag. pub const Save = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (path.len > 0) return c.p.saveTo(c.id, path); if (c.pane.file) |file| if (file.output == null) return c.p.saveFile(c.id); - if (c.pane.file != null or c.pane.isTerminal()) c.p.startSavePrompt(c.pane); + if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, .save); } }; -/// An empty scratch buffer below the calling pane, inheriting its directory. -/// No file exists yet, so Save asks for a path prefilled with that directory -/// and, once written, the buffer becomes an ordinary file pane. pub const New = struct { pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true }; pub fn run(c: Ctx) void { @@ -646,22 +425,10 @@ pub const Newcol = struct { pub const Del = struct { pub fn run(c: Ctx) void { - c.p.absorbVWeight(c.id); - c.p.layoutRemove(c.id); - c.p.deinitPane(c.pane); - c.p.panes[c.id] = null; - if (c.p.active == c.id) c.p.active = c.p.prevFocus(c.id) orelse { - c.p.quit = true; - c.p.emit(.quit); - return; - }; + c.p.removePane(c.id) catch |err| c.p.reportError(c.id, "close", err); } }; -/// Project this terminal's displayed cell foregrounds and backgrounds through -/// the current Pardes theme. The emulator keeps its original colour state; -/// only this pane's rendered cells change, so OSC queries and later resets -/// remain truthful. pub const Filter = struct { pub fn run(c: Ctx) void { if (!c.pane.isTerminal()) return; @@ -671,22 +438,7 @@ pub const Filter = struct { pub const Delcol = struct { pub fn run(c: Ctx) void { - const f = c.p.layoutFindTerm(c.id) orelse return; - var ids: [pardes.MAX_PANES]usize = undefined; - const nids = c.p.col_n[f.col]; - for (0..nids) |k| ids[k] = c.p.col_terms[f.col][k]; - for (ids[0..nids]) |tid| { - if (c.p.panes[tid]) |tt| { - c.p.layoutRemove(tid); - c.p.deinitPane(tt); - c.p.panes[tid] = null; - } - } - if (c.p.panes[c.p.active] == null) c.p.active = c.p.prevFocus(c.p.active) orelse { - c.p.quit = true; - c.p.emit(.quit); - return; - }; + c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err); } }; @@ -702,7 +454,7 @@ pub const Newtty = struct { /// The horizontal mirror of the vertical stacking `New` does. pub const Joincol = struct { pub fn run(c: Ctx) void { - c.p.joinCol(); + layout.joinCol(c.p); } }; @@ -717,35 +469,21 @@ pub const Tutor = struct { pub const Help = struct { pub const output: OutputTraits = .{ .name = config.help_buffer }; pub fn run(c: Ctx) void { - output_pane.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err); + panes.Output.openHelp(c.p, c.id, "") catch |err| c.p.reportError(c.id, "help", err); } }; -/// Where pardes read its startup commands from — the path, printed into an -/// output buffer, `SPC f c` or the word executed anywhere. -/// -/// The one question docs/config.md cannot answer, because the answer depends -/// on the machine: XDG_CONFIG_HOME if it is set and absolute, else -/// ~/Library/Application Support/pardes/init on macOS and -/// ~/.config/pardes/init everywhere else. Printing it beats documenting it — -/// the row is ordinary text, so a right click on it opens the file, and when -/// there is no file there yet the path is still exactly what you needed to -/// know. pub const Config = struct { pub const output: OutputTraits = .{ .name = config.config_buffer }; pub fn run(c: Ctx) void { - output_pane.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err); + panes.Output.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err); } }; -/// Read back what the message rows said. A message row is cleared by the next -/// keystroke, so anything reported while you were looking at another pane was -/// gone before you could read it — a failed save, a watcher's reload, a -/// builtin's complaint. pub const Messages = struct { pub const output: OutputTraits = .{ .name = config.messages_buffer }; pub fn run(c: Ctx) void { - output_pane.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); + panes.Output.openMessages(c.p, c.id) catch |err| c.p.reportError(c.id, "messages", err); } }; @@ -754,33 +492,34 @@ pub const Messages = struct { pub const Changelog = struct { pub const output: OutputTraits = .{ .name = config.changelog_buffer }; pub fn run(c: Ctx) void { - output_pane.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); + panes.Output.openChangelog(c.p, c.id) catch |err| c.p.reportError(c.id, "changelog", err); } }; -/// Source code for the concrete backend implementation of a Panel*/scene -/// effect. The bytes are embedded at build time, so this works from an -/// installed executable rather than depending on a source checkout. pub const EffectCode = struct { pub const takes_arg = true; pub const enabled = capabilities.panel_transitions or capabilities.scene_shaders; pub const output: OutputTraits = .{ .name = config.effect_code_buffer }; pub fn run(c: Ctx) void { if (comptime enabled) - output_pane.openEffectCode(c.p, c.id, c.arg orelse return) catch |err| + panes.Output.openEffectCode(c.p, c.id, c.arg orelse return) catch |err| c.p.reportError(c.id, "effect code", err) else unreachable; } }; -// ---- search ---- +pub const Mini = struct { + pub const takes_arg = true; + 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| + c.p.reportError(c.id, "mini", err); + } +}; -// The two builtins that ASK for something — Find walks file NAMES under this -// pane's directory, Grep file CONTENTS under every pane's. With an argument -// there is nothing to ask: it IS the pattern, so the walk runs now (this is -// what a `Grep` executed with a selection chorded to it means). Without one -// they arm the same tag input `/` does, and Enter runs it (submitSearch). +// ---- search ---- pub const Find = struct { pub const takes_arg = true; @@ -789,7 +528,7 @@ pub const Find = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return c.p.runSearch(c.id, pat, .find, .top) catch |err| c.p.reportError(c.id, "find", err); - c.p.startSearch(c.pane, config.find_marker); + c.p.startPrompt(c.pane, .{ .search = config.find_marker }); } }; @@ -802,51 +541,38 @@ pub const Grep = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep, .top) catch |err| c.p.reportError(c.id, "grep", err); - c.p.startSearch(c.pane, config.grep_marker); + c.p.startPrompt(c.pane, .{ .search = config.grep_marker }); } }; // ---- the window group ---- -// The DESTINATION is the name — a word, the way a tag holds Del or Save — -// because these names live in the same vocabulary as everything else here: -// `Wh` would be a leader key path leaking into the text you can middle-click. -// Plain English words are safe for exactly these five: focus is the cheapest -// thing to change by accident (nothing is edited, closed or written) and the -// way back is the opposite word. - pub const Left = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .left); + layout.focusDir(c.p, c.id, .left); } }; pub const Down = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .down); + layout.focusDir(c.p, c.id, .down); } }; pub const Up = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .up); + layout.focusDir(c.p, c.id, .up); } }; pub const Right = struct { pub fn run(c: Ctx) void { - c.p.focusDir(c.id, .right); + layout.focusDir(c.p, c.id, .right); } }; // ---- the jump group ---- -// Where focus HAS BEEN, as three verbs and a list over the one stack pardes -// keeps (Pardes.jumps — see trackJump for what gets onto it). Builtins rather -// than bare key handlers for the same reason the four directions are: one -// implementation, reachable by chord, by `SPC j ...`, and by executing the -// word wherever it is written. - /// Ctrl-o: one step back into the history. pub const Back = struct { pub fn run(c: Ctx) void { @@ -861,65 +587,27 @@ pub const Forward = struct { } }; -/// vim's Ctrl-^: the pane you were in before this one, whichever it was — the -/// hop you press twice a minute and never want to count steps for. Body-normal -/// Esc is this, which is what makes alternating between two panes one key you -/// hold down: two files, or a file and its shell, or a file and a +Search. -/// -/// It does NOT move the stack cursor: it goes somewhere, so trackJump records -/// it like any other move, and that is exactly what makes it an involution — -/// after the hop, the pane you came from is the newest OTHER pane, so pressing -/// it again comes straight back. Back/Forward walk history; this one makes it. -/// -/// It replaced a `Toggleterm` that hopped specifically between the newest DOC -/// and the newest TERMINAL. That distinction never earned its keep: it made Esc -/// unpredictable (which of three panes you landed on depended on their kinds), -/// and it could not alternate between two files at all — the case you hit most. -/// "The pane before this one" needs no kinds and is the same key twice. pub const Last = struct { pub fn run(c: Ctx) void { var i = c.p.njumps; while (i > 0) { i -= 1; const j = c.p.jumps[i]; - // `.keep`: Esc is a RETURN, and the pane still holds the view it - // was left with. Recentring it moved the whole screen to show a line - // that was, nearly always, already on it. - // - // Not `line = 0`, which focusPaneLine already understands as "focus - // and touch nothing": a background pane's view CAN move while you - // are away — the wheel scrolls the pane under the pointer, not the - // active one, and a resize recomputes geometry without revealing any - // cursor — so `.keep` restores the recorded cursor and lets - // ensureCursorVisible pull it back on screen by the least it can. + // Restore the cursor without recentering the pane's retained view. if (j.pane != c.id) return c.p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .keep); } } }; -/// The same stack, as text you can read and click. Not a copy of it and not a -/// second list kept in step — the buffer is RENDERED from the stack when you -/// ask, the way +Search is rendered from a walk. pub const Jumplist = struct { pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true }; pub fn run(c: Ctx) void { - output_pane.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); + panes.Output.openJumps(c.p, c.id) catch |err| c.p.reportError(c.id, "jumplist", err); } }; // ---- the language group ---- -// Reached as `SPC l ` — see leader_path for why the prefix -// exists. They are builtins rather than bare keys for the same reason Save is -// one: the word is executable wherever it appears, so a middle-click on -// `Hover` in a tag does what `SPC l k` does. The five GOTOS are not here — -// helix binds them under `g` as motions, and a motion has no business being a -// word you can click. -// -// Most of them are one call: ask, and let the answer land in lspResponse. -// Nothing here blocks or knows what a backend is — swapping backends changes -// lsp.query and not one line below. - pub const Hover = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .hover, ""); @@ -956,10 +644,6 @@ pub const WsDiagnostics = struct { } }; -// The four hierarchy words, protocol-only (LSP 3.16/3.17): the in-process -// Zig backend has no analyser for them, so in a `.zig` pane they answer -// nothing. helix has no binding for any of the four. - pub const Callers = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .incoming_calls, ""); @@ -984,16 +668,12 @@ pub const Subtypes = struct { } }; -// The two that need a word from the user, handled exactly the way Find and -// Grep are: an argument means run it now (a selection chorded onto the name), -// no argument arms the tag input and Enter submits (submitSearch). - pub const Rename = struct { pub const takes_arg = true; pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); - c.p.startSearch(c.pane, config.rename_marker); + c.p.startPrompt(c.pane, .{ .search = config.rename_marker }); } }; @@ -1002,16 +682,10 @@ pub const WsSymbols = struct { pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); - c.p.startSearch(c.pane, config.symbol_marker); + c.p.startPrompt(c.pane, .{ .search = config.symbol_marker }); } }; -// Introspection. A language backend that answers nothing looks exactly like -// one that is broken — from the outside, `gd` doing nothing is both "there is -// no definition" and "the analyser threw and we swallowed it". These two are -// how you tell: Lspinfo says what the backend IS, Lspwhy says what it just DID -// and where it stopped. - pub const Lspinfo = struct { pub fn run(c: Ctx) void { c.p.lspRequest(c.id, .status, ""); @@ -1024,123 +698,324 @@ pub const Lspwhy = struct { } }; -// ---- the machine's address space (bare metal only) ---- -// -// Three words gated by `board_memory.enabled`, which is a fact about the -// TARGET (freestanding, and not wasm) rather than about `pardes.platform` — -// see the reasoning there. Today that is exactly `-Dplatform=esp32p4`; what makes -// it the right predicate is that a second bare-metal port gets them without -// anyone remembering to add an enum arm, and the browser never does. -// Elsewhere they are absent from the command enum, the help index, the leader -// table and the dispatcher, which is the gate ThemeFile and DumpThemes -// already use. -// -// They are not a debugger and not a privilege: with no OS there is no MMU, no -// supervisor and no process, so pardes IS the system software and all 2^32 -// addresses are already its own. RAM, the peripheral registers behind the -// console it is talking to you over, and its own .text are one flat space, and -// a word that could reach only part of it would be pretending to be an -// application. What you actually reach for these for is the case a hosted -// editor never has: the display did not come up, and the question is whether -// the register you thought you wrote holds what you thought you wrote. -// -// Implementation, parsing, the volatile accesses and the clamp are all in -// board_memory.zig, the way the PDF words live in pdf_pane.zig — these three -// structs are the words, their argument contract, and where the answer goes. - -/// `Peek [count]` — count 32-bit words (default 1) as `addr: value` -/// rows, hex or decimal address, refused rather than trapped when unaligned. pub const Peek = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.peek_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.peek(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "peek", err); + if (comptime enabled) { + Board.peek(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "peek", err); + } else unreachable; } }; -/// `Poke ` — one 32-bit store, answered on the message row with -/// the value written AND the value that reads back, which on MMIO is the -/// interesting half (see board_memory.poke). pub const Poke = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.poke(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "poke", err); + if (comptime enabled) { + Board.poke(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "poke", err); + } else unreachable; } }; -/// `Hexdump [len]` — len bytes (default 256) in `hexdump -C`'s layout. pub const Hexdump = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.hexdump_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; - } - fn apply(c: Ctx) void { - board_memory.hexdump(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "hexdump", err); + if (comptime enabled) { + Board.hexdump(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "hexdump", err); + } else unreachable; } }; -/// `Gpio ` — flip one pad, answered on the message row as `0->1`. Bare `Gpio` draws JP1's -/// pinout into a pane instead, because the first question about a header is which pins it has. -/// -/// The only word here whose argument is DECIMAL, and `board_memory.gpio` says why at length: a -/// GPIO number is part of a name, not an address. pub const Gpio = struct { pub const takes_arg = true; - pub const enabled = board_memory.enabled; + pub const enabled = Board.enabled; pub const output: OutputTraits = .{ .name = config.gpio_buffer }; pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; + if (comptime enabled) { + Board.gpio(c.p, c.id, c.arg orelse "") catch |err| + c.p.reportError(c.id, "gpio", err); + } else unreachable; } - fn apply(c: Ctx) void { - board_memory.gpio(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "gpio", err); - } -}; - -// ---- somebody else's tree ---- - -/// `9p ` — walk to a file in ANOTHER pardes's tree, read it, and -/// open the bytes in a pane. -/// -/// THE OTHER END OF `--fs9`, and the reason the client in `src/9p.zig` is not a -/// library with no caller: one pardes serves acme's control filesystem over -/// 9P2000 on a unix socket, and this word is the second one reading it. `9p -/// work /1/body` shows you what pane 1 of the session called `work` is holding, -/// from a pane in this session, with no mount and no `plan9port` in the way. -/// -/// A DIAL IS A NAME OR A PATH: `work` resolves through the same -/// `fs9_service.socketPath` that bound it, and anything with a `/` in it is a -/// socket path taken as given. Unix sockets only for now — a 9P server across a -/// network is tunnelled (docs/9p.typ §10), and this word is not the place to -/// decide otherwise. -/// -/// It BLOCKS while it fetches, bounded by `fs9_client.budget_ms`, exactly the -/// way Look blocks on a disk read; `src/fs9_client.zig` argues that at length -/// and enforces it with a deadline rather than a promise. -pub const @"9p" = struct { - pub const takes_arg = true; - pub const enabled = fs9_client.supported; - /// Prose and not a list: the bytes are a file's, so n/N walks its words the - /// way it walks any document's, and there is nothing here to step to. - pub const output: OutputTraits = .{ .name = config.ninep_buffer, .doc = true }; - pub fn run(c: Ctx) void { - if (comptime enabled) apply(c) else unreachable; +}; + +const Board = struct { + const enabled = pardes.platform == .esp32p4; + + comptime { + if (enabled and pardes.hosted) @compileError("an OS is not bare metal"); + if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding"); + if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus"); } - fn apply(c: Ctx) void { - fs9_client.fetch(c.p, c.id, c.arg orelse "") catch |err| - c.p.reportError(c.id, "9p", err); + + // Bound output sent over the board's 115200-baud console. + const max_bytes: u32 = 4096; + const max_words: u32 = max_bytes / 4; + + const address_space_end: u64 = 1 << 32; + + const Error = error{ + MissingAddress, + BadAddress, + BadCount, + MissingValue, + BadValue, + MisalignedAddress, + ExtraArgument, + BadPin, + NoPads, + }; + + fn parseHex(comptime T: type, tok: []const u8, bad: Error) Error!T { + const body = if (tok.len > 2 and tok[0] == '0' and (tok[1] | 0x20) == 'x') tok[2..] else tok; + return std.fmt.parseInt(T, body, 16) catch bad; + } + + fn parseAddr(tok: []const u8) Error!u32 { + return parseHex(u32, tok, Error.BadAddress); + } + + fn parseCount(tok: []const u8) Error!u64 { + return parseHex(u64, tok, Error.BadCount); + } + + fn parseValue(tok: []const u8) Error!u32 { + return parseHex(u32, tok, Error.BadValue); + } + + // Callers reject unaligned words; volatile accesses must reach the device. + fn readWord(addr: u32) u32 { + const cell: *allowzero const volatile u32 = @ptrFromInt(@as(usize, addr)); + return cell.*; + } + + fn writeWord(addr: u32, value: u32) void { + const cell: *allowzero volatile u32 = @ptrFromInt(@as(usize, addr)); + cell.* = value; + } + + fn readByte(addr: u32) u8 { + const cell: *allowzero const volatile u8 = @ptrFromInt(@as(usize, addr)); + return cell.*; + } + + const Limit = enum { + console, + space, + }; + + const Extent = struct { + count: u32, + limit: ?Limit, + }; + + fn extent(addr: u32, requested: u64, unit: u32, cap: u32) Extent { + var count = requested; + var limit: ?Limit = null; + if (count > cap) { + count = cap; + limit = .console; + } + const fits = (address_space_end - addr) / unit; + if (count > fits) { + count = fits; + limit = .space; + } + return .{ .count = @intCast(count), .limit = limit }; + } + + fn writeNote(w: *std.Io.Writer, e: Extent, requested: u64, unit_name: []const u8) !void { + switch (e.limit orelse return) { + .console => try w.print( + "clamped: 0x{x} {s} requested, 0x{x} shown (0x{x}-byte cap, one 115200-baud console)\n", + .{ requested, unit_name, e.count, max_bytes }, + ), + .space => try w.print( + "clamped: 0x{x} {s} requested, 0x{x} shown (the 32-bit address space ends at 0x100000000)\n", + .{ requested, unit_name, e.count }, + ), + } + } + + fn peek(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 0x1; + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + const e = extent(addr, requested, 4, max_words); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "words"); + for (0..e.count) |i| { + const at = addr + @as(u32, @intCast(i * 4)); + try out.writer.print("{x:0>8}: {x:0>8}\n", .{ at, readWord(at) }); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Peek }, content); + } + + fn poke(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const value = try parseValue(it.next() orelse return Error.MissingValue); + if (it.next() != null) return Error.ExtraArgument; + if (addr % 4 != 0) return Error.MisalignedAddress; + + writeWord(addr, value); + const back = readWord(addr); + var buf: [96]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint( + &buf, + "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", + .{ addr, value, back }, + ) catch unreachable); + } + + fn gpio(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const tok = it.next() orelse { + const content = try p.gpa.dupe(u8, Header.text); + return fill(p, id, .{ .cmd = .Gpio }, content); + }; + if (it.next() != null) return Error.ExtraArgument; + const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin; + + const toggle = p.host.vtable.gpio_toggle orelse return Error.NoPads; + var was: u8 = 0; + var now: u8 = 0; + if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; + + var buf: [48]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); + } + + const row_bytes: u32 = limits.hexdump_row_bytes; + + fn hexdump(p: *Pardes, id: usize, argument: []const u8) !void { + var it = std.mem.tokenizeAny(u8, argument, " \t\r\n"); + const addr = try parseAddr(it.next() orelse return Error.MissingAddress); + const requested = if (it.next()) |tok| try parseCount(tok) else 0x100; + if (it.next() != null) return Error.ExtraArgument; + + const e = extent(addr, requested, 1, max_bytes); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try writeNote(&out.writer, e, requested, "bytes"); + var row: u32 = 0; + while (row < e.count) : (row += row_bytes) { + const n = @min(row_bytes, e.count - row); + var bytes: [row_bytes]u8 = undefined; + for (0..n) |i| bytes[i] = readByte(addr + row + @as(u32, @intCast(i))); + try out.writer.print("{x:0>8} ", .{addr + row}); + for (0..row_bytes) |i| { + if (i == row_bytes / 2) try out.writer.writeByte(' '); + if (i < n) + try out.writer.print(" {x:0>2}", .{bytes[i]}) + else + try out.writer.writeAll(" "); + } + try out.writer.writeAll(" |"); + for (0..n) |i| try out.writer.writeByte( + if (bytes[i] >= 0x20 and bytes[i] < 0x7f) bytes[i] else '.', + ); + try out.writer.writeAll("|\n"); + } + const content = try out.toOwnedSlice(); + try fill(p, id, .{ .cmd = .Hexdump }, content); + } + + fn fill(p: *Pardes, id: usize, from: panes.Output.Origin, content: []u8) !void { + const pane = p.panes[id] orelse { + p.gpa.free(content); + return error.MissingPane; + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + try panes.Output.fillResults(p, id, dir, from, "", content, null); + } + + test "the clamp reports the tighter bound and never wraps the address space" { + const eq = std.testing.expectEqual; + try eq(Extent{ .count = 3, .limit = null }, extent(0x4ff40000, 3, 4, max_words)); + try eq(Extent{ .count = max_words, .limit = .console }, extent(0x4ff40000, 99_999, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0, 100_000, 1, max_bytes)); + try eq(Extent{ .count = 16, .limit = .space }, extent(0xfffffff0, 64, 1, max_bytes)); + try eq(Extent{ .count = 4, .limit = .space }, extent(0xfffffff0, 64, 4, max_words)); + try eq(Extent{ .count = 0, .limit = .space }, extent(0xffffffff, 1, 4, max_words)); + try eq(Extent{ .count = max_bytes, .limit = .console }, extent(0xffff0000, 1 << 20, 1, max_bytes)); + } + + test "a clamp note is written exactly when something was clamped" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + try writeNote(&w, extent(0x4ff40000, 3, 4, max_words), 3, "words"); + try std.testing.expectEqualStrings("", w.buffered()); + + try writeNote(&w, extent(0x4ff40000, 99_999, 4, max_words), 99_999, "words"); + try std.testing.expectEqualStrings( + "clamped: 0x1869f words requested, 0x400 shown (0x1000-byte cap, one 115200-baud console)\n", + w.buffered(), + ); + + w = .fixed(&buf); + try writeNote(&w, extent(0xfffffff0, 64, 1, max_bytes), 64, "bytes"); + try std.testing.expectEqualStrings( + "clamped: 0x40 bytes requested, 0x10 shown (the 32-bit address space ends at 0x100000000)\n", + w.buffered(), + ); + } + + test "every literal is hex, with or without the prefix" { + const eq = std.testing.expectEqual; + try eq(0x4ff40000, parseAddr("0x4ff40000")); + try eq(0x4ff40000, parseAddr("4ff40000")); + try eq(0x4ff40000, parseAddr("0X4FF40000")); + try eq(0x4ff40000, parseAddr("4FF40000")); + try eq(0x100, parseCount("100")); + try eq(0x256, parseCount("256")); + try eq(0xdeadbeef, parseValue("deadbeef")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x100000000")); + try std.testing.expectError(Error.BadAddress, parseAddr("0x")); + try std.testing.expectError(Error.BadAddress, parseAddr("nope")); + try std.testing.expectError(Error.BadAddress, parseAddr("12g4")); + try std.testing.expectError(Error.BadCount, parseCount("-1")); + try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000")); + } + + test "the pinout fits the board's own grid, in two aligned columns" { + const cols: usize = @import("pardes_config").esp32p4_cols; + const usable = cols - 7; + + var rows: usize = 0; + var pins: usize = 0; + var first_bar: ?usize = null; + var it = std.mem.splitScalar(u8, Header.text, '\n'); + while (it.next()) |line| { + try std.testing.expect(line.len <= usable); + rows += 1; + const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue; + if (line[line.len - 1] == '+') continue; + pins += 1; + if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar; + } + try std.testing.expectEqual(@as(usize, 13), pins); + try std.testing.expect(rows > 15); + + try std.testing.expect(std.mem.indexOf(u8, Header.text, "GPIO 20 | 17 |") != null); + try std.testing.expect(std.mem.indexOf(u8, Header.text, "| 8 | --") != null); + } + + test "board addresses are hexadecimal and GPIO numbers are decimal" { + try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10)); + try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20")); + try std.testing.expect(20 != 0x20); } }; diff --git a/src/config.zig b/src/config.zig index 331cb4ab..5dcfef01 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,39 +1,14 @@ -//! Every syntactic choice pardes makes, in one file: which key runs what, -//! which mouse button means what, how a looked-at word is SPELLED, and the -//! handful of layout numbers that are taste rather than structure. -//! -//! The point is the editing session, not the architecture: retargeting a key, -//! a chord or a piece of Look syntax is an edit HERE and nowhere else. Nothing -//! below is read at runtime from a file — this IS the config format, recompiled -//! — so a binding may be any comptime expression and a wrong one is a compile -//! error rather than a silent no-op. -//! -//! Order is deliberate. PART 1 is pardes's OWN vocabulary, the part a user -//! actually fiddles with, so it is where a reader lands. PART 2 is Look/Exec -//! syntax. PART 3 is the helix keymap, which is under a differential-testing -//! contract — see the banner there before touching it. -//! -//! These COMPILED bindings are distinct from the small startup command file -//! described in docs/config.md. That file can run builtins such as `Theme` -//! and `Font`; it does not replace or mutate this keymap at runtime. -//! -//! What is NOT a binding: a named key's own identity. Insert mode's Backspace, -//! Delete, Enter and Tab are dispatched on `Key.` in handleInsert and -//! stay there — Backspace deleting backwards is what the key IS, not a choice -//! anyone remaps. `Ctrl-w` deleting a word backwards is a choice, and it is -//! here. const std = @import("std"); +const builtin = @import("builtin"); +const layout = @import("layout.zig"); +const limits = @import("memory.zig").limits; const pardes = @import("pardes.zig"); const builtins = @import("builtins.zig"); const Key = pardes.Key; const Mouse = pardes.Mouse; const Builtin = builtins.registry.Builtin(); -/// One key press a binding matches. `shift` is only consulted when a binding -/// ASKS for it: shift is already carried in the codepoint for anything -/// printable (`A` is `A`, not shift-`a`), so the one chord that needs the flag -/// is Shift-Esc, on a key that has no shifted codepoint. pardes.zig's `hit` -/// is the matcher. +// Printable shift is encoded in cp; shift only constrains chords that request it. pub const Chord = struct { cp: u21, ctrl: bool = false, @@ -41,62 +16,17 @@ pub const Chord = struct { shift: bool = false, }; -// A binding is a LIST because most have two spellings that must reach the same -// arm — a letter and an arrow, `Ctrl-f` and PageDown. One list, one dispatch -// site; the `or` chains this replaces had the modifier logic written out three -// ways (the old is/isC/isA). - -// ============================================================================ -// PART 1 — pardes's own bindings. Nothing here is inherited from anywhere; -// these are the ones to fiddle with. -// ============================================================================ - -// ---- the SPC leader ---- - -/// opens the leader: a key path from here runs a BUILTIN with no arguments. -/// Body normal mode only — a tag is always insert, and a tty pane's keys -/// belong to the program. +// Leader paths apply in body normal mode, not tags or raw terminals. pub const leader: []const Chord = &.{.{ .cp = ' ' }}; -/// Help's key, honored at ANY depth: it lists what the prefix typed so far can -/// still reach. Also Help's own path below, so the character is spelled once. pub const leader_help: u8 = '?'; -/// what an unlisted builtin's path is until someone says otherwise: a value no -/// key path can be, so the loop at the bottom of the table can refuse it const undecided: []const u8 = ""; -/// SPC leader: ONE key path per builtin, the whole remapping surface. A new -/// enum field is a compile error until someone has DECIDED its path — that is -/// what `undecided` and the loop under the table are for, and it used to be -/// EnumArray.init's own doing (it demands every field). It cannot be any more: -/// the gui-only builtins are not fields of this literal's type on tty or web, -/// so the literal cannot name them and a default is the only way to have both. -/// Groups are just shared first letters (f files, h docs, c columns, t -/// toggles, s session, l language, w windows). -/// -/// `null` = this builtin's shortcut is not a leader path. Look and Exec are -/// the two: their shortcuts are Enter/Tab and the two mouse buttons below, and -/// a third spelling under SPC would be a key that does nothing you cannot -/// already do with the key your hand is on. The option is the honest type — -/// "every builtin has a leader path" was only ever true by accident. +// null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, - // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep - // the letters they always had — `SPC d` is Del, `SPC k` is Kill. - // - // Helix puts these on bare `` letters, and an earlier pass followed - // it there, which cost `d`, `k`, `s`, `h` and the session group. That is - // the wrong trade: those five are pardes's most-pressed keys and predate - // the language work, whereas an LSP command is something you reach for - // deliberately and can afford one more keystroke. Each one still keeps - // HELIX'S OWN LETTER inside the group, so the mapping is `X` -> - // `SPC l X` with nothing to re-learn but the prefix. - // - // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy` - // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so - // there was never a reason to move them. They are in PART 3. .Hover = "lk", .Rename = "lr", .CodeAction = "la", @@ -107,110 +37,61 @@ pub const leader_path = paths: { .WsDiagnostics = "lD", .Lspinfo = "li", .Lspwhy = "lw", - // The hierarchy group, protocol servers only. `c`/`t` were free under - // `l`; helix has no spelling for these at all (they postdate its - // keymap), so the letters are pardes's own: who Calls me / whom I - // Call, and the Type lattice up / down. .Callers = "lc", .Callees = "lC", .Supertypes = "lt", .Subtypes = "lT", .Del = "d", - // A terminal-pane tag owns this presentation switch. It deliberately - // has no global leader path: executing the word beside that terminal - // makes the pane-local scope visible at the point of use. .Filter = null, - .Kill = "k", - // THE CLIPBOARD MENU, on helix's own five letters and nowhere else. - // These are the only paths in the table that keep their helix spelling - // unprefixed, and they can: `y` `Y` `p` `P` `R` were free at the top - // level, and moving them into a group would have made the one thing - // here that IS helix's leader stop looking like it. - // - // Bare `y`/`p`/`P`/`R` remain the DEFAULT register — that split is the - // whole design (see builtins.zig's clipboard section), and it is why - // an ordinary delete no longer reaches past the editor. + .Kill = null, .ClipYank = "y", .ClipYankMain = "Y", .ClipPaste = "p", .ClipPasteBefore = "P", .ClipReplace = "R", - // the `f` file group (spacemacs): Save left vim's `w` to join Find and - // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", .New = "fn", .Newtty = "nt", .Find = "ff", .Grep = "fg", - // the config FILE joins the file group: `SPC f c` says where pardes - // read (or would read) its startup commands from. .Config = "fc", .Tutor = "ht", .Changelog = "hc", - // `Messages` joins the help group because it answers the same kind of - // question they do — "what did that say?" — about lines that have - // already left the screen. .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", .Debug = "td", - // `Msg` takes the text to post, so it has no path, for the reason - // `Theme` and the two acme verbs below have none: a key path names a - // builtin and can never carry an argument. Bare `Msg` still runs — it - // reports itself through the error path. .Msg = null, .Colors = "tc", .Wrap = "tw", .Tagbottom = "tb", .NextColor = "tn", - // the theme picker joins the toggles it belongs with; `Theme` itself takes - // a NAME, and a key path can never carry one, so it has none (the same - // reason Look and Exec have none) .ThemeSel = "tt", .Theme = null, - // ...and `Shell` takes the name of a binary, so it has none either .Shell = null, - // the image toggles join the same `t` group; Palette takes `l` because - // `p` is Petscii's and `c` is Colors'. .Petscii = "tp", .Palette = "tl", .Ascii = "ta", .Dump = "sd", .Restore = "sr", - // the `w` window group `Save` vacated: the four directional focus moves - // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus - // `t` for the file<->terminal hop. .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", - // the `j` JUMP group, its own letter rather than more of `w`: the window - // group moves focus by GEOMETRY (the pane left of this one), these move it - // by TIME (the pane I was in before). `o` and `i` are the letters of the - // chords that do the same thing, `jj` is the group's obvious verb, and - // `jl` is the list itself. .Back = "jo", .Forward = "ji", .Last = "jj", .Jumplist = "jl", - // the two acme verbs: keys and buttons, no leader path — see above .Look = null, + .Mini = null, .Exec = null, }); - // The GUI's font setting and picker, in the same `t` group as the theme - // picker they mirror. Their explicit availability metadata means tty and - // web Builtin enums have no fields for them. `Font` takes a NAME and - // `TaglineSize` takes a percentage, so neither has a path: a leader chord - // cannot carry either argument. if (builtins.capabilities.font_picker) { table.set(.FontSel, "tf"); table.set(.Font, null); table.set(.TaglineSize, null); } - // Panel transitions are implemented by the fixed cell grid in TTY and by - // shader-capable native GUI shells. The DOM web shell does not advertise - // them until it has an equivalent renderer. if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -229,105 +110,73 @@ pub const leader_path = paths: { table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); } - // Takes the name of an effect builtin; a leader path cannot carry it. - // The web build has no runnable effect argument, so it has no command, - // help row, dispatcher case, or leader entry for EffectCode either. if (builtins.EffectCode.enabled) table.set(.EffectCode, null); - // Native-only filesystem theme commands. ThemeFile needs an operand and - // DumpThemes is intentionally occasional, so both stay word-executed - // rather than spending leader chords. if (pardes.hosted) { table.set(.ThemeFile, null); table.set(.DumpThemes, null); + table.set(.Mount, null); + table.set(.Unmount, null); } - // ...and the one native word that DOES earn a chord. Gated on `can_attach` - // and NOT on `hosted`, because this table may only name a builtin that - // exists: macOS is hosted but never polls `takeAttach`, so the two words - // below are compiled out there and naming them would be a compile error — - // which is the good outcome, and the reason the predicate exists. if (pardes.can_attach) { - // In the `s` session group beside Dump and Restore. Bare Attach means - // "whichever detached session is there", which is the whole case worth - // a key; the named form is typed, like every other builtin that takes - // an operand. Safe to press by accident, uniquely among the three: it - // connects before it swaps, so nothing to attach to costs you a - // message row. table.set(.Attach, "sa"); - // ...and the way back out, which is where the group runs out of - // letters: `sd` has been Dump's since before there was anything to - // detach from, and Detach is not worth breaking that muscle memory - // for. A capital where the lowercase is taken is what this table - // already does one group over (`lS` beside `ls`, `lD` beside `ld`). table.set(.Detach, "sD"); } - // The bare-metal memory words. Peek, Poke and Hexdump all take an ADDRESS, - // so none of them can have a leader path for the reason Theme and Msg have - // none: a key path names a builtin and can never carry an operand. if (builtins.Peek.enabled) { table.set(.Peek, null); table.set(.Poke, null); table.set(.Hexdump, null); table.set(.Gpio, null); } - // The 9P client word. Takes a dial AND a path, so it has no leader path - // for the reason the three above have none, twice over. Its gate is the - // presence of unix sockets, which is narrower than `hosted`. - if (builtins.@"9p".enabled) table.set(.@"9p", null); - // The pane-local PDF commands exist only in MuPDF builds through their - // explicit registry availability, so name their paths inside the same - // comptime branch. PdfTint/PdfFit retain their display slots and - // PdfSections takes the mnemonic `s` between them. if (pardes.pdf_enabled) { table.set(.PdfTint, "ti"); table.set(.PdfSections, "ts"); table.set(.PdfFit, "tz"); } - // ...and the property EnumArray.init used to give for free: every builtin - // this build HAS is a builtin someone decided a path (or a null) for. for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { if (std.mem.eql(u8, p, undecided)) @compileError("builtin has no leader path decided: " ++ @tagName(b)); }; break :paths table; }; -// ---- the acme chords ---- +test "Space-k is unbound while Kill and other k chords remain available" { + try std.testing.expect(leader_path.get(.Kill) == null); + try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?); + try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?); + try std.testing.expectEqualStrings("d", leader_path.get(.Del).?); + for (pardes.builtin_rows) |row| if (row.cmd == .Kill) { + try std.testing.expect(row.path == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "SPC") == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "Kill") != null); + }; + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_row = 1; + while (p.nextEffect()) |_| {} + p.update(.{ .key = .{ .cp = ' ' } }); + try std.testing.expect(p.leader_on); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expect(!p.leader_on and !p.quit); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_row); + try std.testing.expect(p.executeBuiltinLine(p.active, "Kill")); + try std.testing.expect(p.quit); +} -// Look and Execute, the two verbs the whole environment is built on. Each has -// a KEY and a mouse BUTTON, and they are the same two verbs: Enter on a word -// does what a right click on it does. Swap the two `_key` lines for the vim -// reading, where Enter runs the command line. (This pair replaced a -// `swap_enter_tab` bool, which could only ever exchange them — two bindings -// can also be moved somewhere else entirely.) pub const look_key: []const Chord = &.{.{ .cp = Key.enter }}; pub const exec_key: []const Chord = &.{.{ .cp = Key.tab }}; pub const look_button: Mouse.Button = .right; pub const exec_button: Mouse.Button = .middle; -/// sweep, focus, place the cursor, and the left half of every acme chord pub const select_button: Mouse.Button = .left; -/// ...and WHAT those four run. Look and Exec are ORDINARY builtins — the same -/// kind of thing Save and Grep are, executable by name wherever text lives -/// (`Look main.zig` in a tag does what a right click on `main.zig` does) — so -/// the four bindings above are bindings like any other, and these two lines -/// are the whole of what makes them special. Point `look_cmd` at `.Grep` and -/// Enter greps. pub const look_cmd: Builtin = .Look; pub const exec_cmd: Builtin = .Exec; -// ---- windows ---- - -/// helix's window prefix. It stays despite `SPC w` covering the same four -/// builtins because it reaches one place the leader cannot: a pane in raw tty -/// mode never sees SPC (the shell owns every printable key), so this is the -/// only keyboard way out of one. +// Ctrl-w remains available when a terminal owns printable keys. pub const window_prefix: []const Chord = &.{.{ .cp = 'w', .ctrl = true }}; -/// The four directional focus moves, as data: `Ctrl-w ` in a -/// body, and the bare LETTER on a focused tagline, where focus IS the motion -/// (the arrows stay grapheme motion inside the tag, which is why the two -/// spellings are separate fields rather than one list). One table, two -/// readers — and the `cmd` column is what makes the chord discoverable from -/// the builtin as well as the other way round. pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'h' }, .arrow = .{ .cp = Key.left }, .cmd = .Left }, .{ .letter = .{ .cp = 'j' }, .arrow = .{ .cp = Key.down }, .cmd = .Down }, @@ -335,346 +184,92 @@ pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'l' }, .arrow = .{ .cp = Key.right }, .cmd = .Right }, }; -/// global window ops, live in ANY mode (which is why they are Alt-, not a -/// leader path): a new terminal below, and moving this terminal into a fresh -/// column. Alt-c is a deliberate divergence from helix's change-noyank — -/// hxdiff waives it by name. pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }}; pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }}; -// ---- jumps ---- - -/// vim's Ctrl-o / Ctrl-i, walking the focus history back and forward. Global -/// in any mode, like the two Alt- ops above and for the same reason: getting -/// BACK has to work from inside a pane that owns its keys. -/// -/// CAREFUL, and this is why the table has a comment: **Ctrl-i is Tab**. On the -/// wire they are the same byte (0x09), so on a host that speaks only the -/// legacy encoding this binding never fires and 0x09 keeps meaning `exec_key` -/// below — which is the right way round, since Tab-executes is the older and -/// more used of the two. Where the host speaks the kitty keyboard protocol -/// (`CSI 105;5u`) the two are distinct keys and both work. Shift-Esc -/// (tty_toggle_alt) is already spelled on that same bet. -/// -/// Shaped like window_keys: the `cmd` column is what makes the chord -/// discoverable from the builtin as well as the other way round, and it is -/// what keeps ONE implementation — pressing the chord and executing the word -/// `Back` are the same call. +// Legacy Ctrl-i is Tab; distinguishing them requires the kitty keyboard protocol. pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back }, .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, }; -/// `j` off the topbar drops back onto a tagline — the mirror of the `k` that -/// got you there (window_keys' letter, answered by tagNormalKey). pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }}; -/// the topbar has no neighbouring window to walk to, so up here h/l are plain -/// grapheme motion instead pub const topbar_left: []const Chord = &.{.{ .cp = 'h' }}; pub const topbar_right: []const Chord = &.{.{ .cp = 'l' }}; -/// LEAVE the pane you are in — the `Last` builtin — from a pane whose own -/// plain Escape already means something else. A terminal in raw tty mode has -/// had it since it existed (tty_toggle_alt below, same chord, same job); a PDF -/// needs it because Escape there cancels the selection and the search overlay -/// without moving focus out of the document you are reading. -/// -/// On a host that reports no modifier on Escape it arrives as a plain Escape -/// and still means what Escape always means in that pane. pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -// ---- raw tty mode ---- - -/// Ctrl- toggles raw tty mode in and out (terminals only); tty is -/// deliberately off the normal editing path. Not a Chord because the shell can -/// override it at runtime (`--tty-toggle`), so this is only Options' default. pub const tty_toggle_default: u21 = 'b'; -/// the second spelling, for hosts that report modifiers on Escape (the kitty -/// keyboard protocol). Where they don't it arrives as a plain Escape and still -/// means what Escape always means. pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -/// Paste INTO the program a tty pane is running. `SPC p` and the acme 1-3 -/// chord cannot be reached there — the pty owns every keystroke and every -/// button — so raw tty mode needs its own pair, and these are the two a -/// terminal user already has in their hands. -/// -/// The split is the one the whole clipboard design rests on: Ctrl-V types the -/// DEFAULT register (what `y` put there, no round trip, no desktop involved), -/// Ctrl-Shift-V asks for the SYSTEM clipboard. Two spellings for the second -/// because a host may or may not fold the shift into the codepoint, and it -/// must be tested BEFORE the first: `hit` ignores an unasked shift, so plain -/// Ctrl-V matches a shifted key too. -/// -/// What this TAKES: forwardKey encoded both as the same byte, 0x16, so -/// Ctrl-Shift-V was a duplicate ^V and costs nothing to claim. Ctrl-V was -/// readline's quoted-insert, and that one is now unreachable in a tty pane — -/// the trade a terminal user expects, and one line to give back. +// Test the shifted/system-clipboard chord first; unrequested shift is ignored. pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }}; pub const tty_paste_clipboard: []const Chord = &.{ .{ .cp = 'v', .ctrl = true, .shift = true }, .{ .cp = 'V', .ctrl = true }, }; -/// What LEAVING raw tty mode hides on the shell's prompt rows. -/// -/// A prompt is CHROME. `user@host ~/src $` is redrawn on every keystroke, says -/// nothing a second time, and is never what you want to select, look at or -/// edit — so blanking it is most of what turns a scrollback into a readable -/// document, and pardes has always done it (OSC 133 is how it knows). -/// -/// What it USED to take with it was the command you had typed at that prompt, -/// because the two share a grid row and the row was the unit. That command is -/// content: the one thing on the row worth keeping, and the thing you reach -/// for `b` to get at in the first place. OSC 133 marks the two separately — -/// per CELL, not just per row — so `.prompt` blanks the prompt's own cells and -/// leaves the input sitting in the COLUMNS it really occupies. Those columns -/// are not cosmetic: clicking the command in normal mode and pressing the -/// toggle carries the click into the shell's own cursor (promptClickMove), -/// which counts them. -/// -/// `.prompt_and_input` is the older behaviour, kept for anyone who wants a -/// terminal to read as output and nothing else. +// OSC 133 prompt cells are hidden in normal mode; input columns stay intact. pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; -/// The WCAG contrast ratio a filtered terminal foreground has to keep against -/// the default background before `Filter` will paint it in the theme colour -/// the projection chose. 1.0 is "the same colour"; 21.0 is black on white. -/// -/// `Filter` maps the default foreground and background roles FIRST — they are -/// the anchors Ghostty generates the 256-colour projection from — and every -/// other colour after them, by reducing it to its nearest canonical xterm key -/// and reading that key out of the projection. That reduction is a distance -/// between two RGB triples: it knows about hue and nothing about the page. The -/// cube's own corners ARE the two anchors, so the nearest key to a truecolour -/// extreme is the background itself — `\x1b[38;2;255;255;255m` on acme's -/// #ffffea paper resolved to #ffffea, ratio 1.000, text painted the colour of -/// the page under it. Every curated theme owns such a key: 231 on the light -/// one, 0 (ANSI black, which a shell writes with `\x1b[30m`) on both dark ones. -/// -/// A foreground that misses this floor is not mapped. It takes whichever of -/// the theme's own two anchors contrasts BETTER with the background actually -/// behind it, which is the choice the vendored renderer's `contrasted_color` -/// makes between white and black for the same reason. -/// -/// 1.5 is deliberately low: the point is legibility, not WCAG body text, and a -/// theme's comment and dim colours are MEANT to sit close to the page. Measured -/// across the curated three it rejects 12, 16 and 7 of 256 keys, where 3.0 -/// would reject 34, 92 and 41 and flatten a third of the dark palette. It also -/// has to stay below the contrast a theme's own pair achieves — 4.71 on `dark` -/// — or the fallback would fail the very test it answers. 1.0 accepts every -/// projected colour, collapses included. +// Projected colors below this contrast use the theme's more legible anchor. pub const tty_filter_min_contrast: f64 = 1.5; -// ---- the tag line and the topbar ---- - -// The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 -// is where topbar clicks land, so its exact bytes are load-bearing (every -// snapshot golden records the column each word starts at). Comptime-checked -// against the enum in pardes.zig so a rename cannot silently rot it. -// Colors and the scene/panel effects are NOT here: they are display switches -// you flip and forget, and a bar read every frame should not spend width on -// them now that their leader paths are discoverable through Help. NextColor -// stays — it is the one you cycle repeatedly, so a click beats a three-key -// path. -// -// Ordered by day-to-day usefulness, in stable functional groups: creation -// (New/Newcol), search/navigation (Find/Grep), learning (Help/Tutor), session -// persistence (Dump), appearance/diagnostics (NextColor/Debug), then the one -// destructive global action (Kill) exactly last. Find and Grep stay adjacent: -// the former matches file NAMES, the latter their CONTENTS. -// -// Help has to be here even though it is secondary. A bare `pardes` boots -// straight into tty mode (main.zig: `args.len == 1`), where every printable -// key belongs to the shell — so SPC never reaches the leader and `SPC ?`, the -// thing that would tell you the leader exists, is exactly what you cannot -// press. Row 0 is not a pane, so a middle-click on it is dispatched before any -// pane's mode is consulted: Help works in tty mode, which earns its width. pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -/// The default editable tail of a pane's tag, per kind. Save LEADS wherever the -/// pane holds text of its own to write — a file, an output buffer, a terminal's -/// scrollback — because `:` parks at the tail boundary and the established -/// `:w` spelling walks to the first word from there. Images and PDFs get -/// the plain tail: their bytes on disk already are exactly what they are, so -/// there is nothing of the pane's own left to save. Terminals alone expose -/// Filter, the pane-local theme-keyed colour projection. pub const pane_builtins_str = "New Newtty Del"; pub const file_pane_builtins_str = "Save New Newtty Del"; pub const terminal_pane_builtins_str = "Save New Newtty Del Filter"; -/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at -/// the left edge and the builtins are pushed over to end this far short of the -/// pane's, which leaves somewhere to type: a word executed from the tagline is -/// how you run anything here, and with the builtins hard against the edge -/// there was nowhere to put one without first making room. -/// -/// The gap that does the pushing is made of ordinary spaces inside the tag, so -/// both it and this run are editable text — see Pardes.tagGap. Widen it and -/// every untouched tagline reflows on the next frame; taglines you have -/// already edited keep the spacing you left them with. pub const tag_right_pad: u16 = 20; -/// the pane's mode, as ONE character in the layout box at its top-left — live -/// chrome, not text you own. It used to be a three-letter word leading every -/// tagline; the box was already there carrying no information at all, so the -/// mode moved into it and the taglines got their four columns back. -/// -/// These are NOT the initials. A badge you read at a glance every time your -/// eye crosses a pane should LOOK like what it means, and each of these is a -/// mark that already means its mode somewhere else: `^` is the proofreader's -/// caret, the mark that says text goes in HERE; `$` is the shell prompt, and a -/// pane wearing it has the keyboard wired straight to the program on the other -/// end. NORMAL is a SPACE, and the empty box is the point: a pane at rest has -/// nothing waiting to eat what you type, and two thirds of the screen wearing -/// a bullet would be two thirds of the screen saying nothing loudly. (The -/// caret's true form is `‸` U+2038 and the ASCII `^` is only its -/// stand-in — but `^` is in every font ever made and `‸` is in about four, and -/// a mode badge that renders blank on someone's terminal is worse than one -/// spelled with the near-miss.) -/// -/// ONE CODEPOINT each. The box prints a single cell, so a two-character string -/// here would be pushed into one cell as a single grapheme and come out wrong. -/// A font missing the glyph draws a blank box, which is exactly what the box -/// drew before there was anything in it. +// Each mode badge is one codepoint. pub const tag_normal = " "; pub const tag_insert = "^"; pub const tag_tty = "$"; -/// ...and `img` stays a WORD at the head of an image pane's tagline, because -/// it is not a mode: it says what the pane IS, which no amount of watching the -/// box will tell you. The box on an image pane still shows its mode. pub const tag_image = "img"; -/// on a focused tag: yank what the chord would run (the selection, else the -/// word under the cursor). The path is selectable, so this is how you copy it. pub const tag_yank: []const Chord = &.{.{ .cp = 'y' }}; -// ---- the command line and search ---- - -/// vim's command line with acme's vocabulary: focus the pane's own tag in -/// normal mode, parked at the tail's start, and the execute chord runs the -/// word under the cursor (`:w` = Save). pub const command_line: []const Chord = &.{.{ .cp = ':' }}; -/// `/` types a pattern into the tag; n/N walk the results. Same keys on every -/// kind of pane — a terminal with no search armed falls back to n/N as a -/// motion over the lookable tokens in its output. -/// -/// Helix's letters, but NOT helix's commands (it searches by regex and pardes -/// has no regex engine), so these three sit out here rather than under the -/// contract in PART 3 — the differential suites never press them. pub const search: []const Chord = &.{.{ .cp = '/' }}; pub const search_next: []const Chord = &.{.{ .cp = 'n' }}; pub const search_prev: []const Chord = &.{.{ .cp = 'N' }}; -/// Helix `|`: in body normal mode, pipe every file selection through one -/// command typed in the pane's visible tag-tail input, and REPLACE each -/// selection with what the command wrote. pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; -/// Helix `A-|`: the same, and throw the output away. For a command run FOR its -/// effect — `| git add -` — where replacing the text with its chatter is the -/// last thing you want. pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; -/// Helix `!`: run a command with NO stdin and insert what it wrote BEFORE each -/// selection. `date`, a license header, the output of a generator. pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; -/// Helix `A-!`: the same, appended AFTER each selection. pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; -/// What the pane's tag-tail input shows while each of the four is armed, so -/// the prompt says which one you are in — they take the same command line and -/// do very different things to the buffer. `submitPipe` reads the command back -/// from after the marker, so these must stay distinct and non-empty. +// Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; -/// Enter on an armed search input runs it; `escape` (PART 3) abandons it. pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; -// ---- mouse ---- - -/// wheel step, in rows / in columns. The horizontal step is bigger because a -/// column is narrower than a row is tall and a wheel tick should move a -/// comparable distance either way. pub const wheel_rows: i32 = 1; pub const wheel_cols: i32 = 4; -/// Enabled by default: rest the pointer over text for this many animation -/// frames before showing the exact span a right-click Look would expand. -/// Repeated motion inside the same cell does not restart the count; moving to -/// another cell does. Set to `null` to compile the preview out while retaining -/// ordinary mouse hover and resize-handle hints. pub const look_preview_delay_frames: ?u16 = 2; -/// GUI shells rasterize pane-tag text at this percentage of the body face -/// while retaining the same cell geometry. TTY ignores the visual role. +// Tag fonts keep body-cell geometry; valid sizes are 1...100 percent. pub const gui_tagline_font_percent: u8 = 82; comptime { if (gui_tagline_font_percent == 0 or gui_tagline_font_percent > 100) @compileError("config.gui_tagline_font_percent must be in 1...100"); } -/// Physical-pixel rule between the global topbar and pane taglines, in both -/// pixel GUIs (SDL and native macOS, which reach the shared rule in -/// `pardes.taglineBandOffset`). Their smaller font bands retain body-sized grid -/// rows; without an explicit join, centering both bands leaves the two unused -/// half-bands touching and makes a wide strip of the window background show -/// through. Zero disables the rule and joins the two bands directly. +// Physical pixels between topbar and pane tag bands; zero disables the rule. pub const gui_topbar_pane_border_px: u8 = 1; -/// Fixed RGB for that rule, or null to follow the active theme's scrollbar -/// track. The themed default stays quiet across light and dark themes while a -/// build that wants a deliberate accent can pin one here. +// null follows the theme's scrollbar track. pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; -/// Open the SDL window with a transparent buffer, so that a theme declaring NO -/// background of its own (the curated `dark`, every vendored `*_transparent`) -/// shows the desktop through the grid instead of a colour this shell had to -/// invent. That is what the AppKit shell does over its NSVisualEffectView, and -/// on linux the compositor supplies the backdrop — a niri `background-effect -/// { blur true }` window rule, picom, whatever is running. -/// -/// OFF by default because it is not free, and the cost is structural rather -/// than ours: `SDL_ClaimWindowForGPUDevice` refuses a transparent window -/// outright ("The GPU API doesn't support transparent windows", SDL_gpu.c, -/// still upstream), because D3D12 has no transparent swapchain and the API -/// says no everywhere rather than only where it must. A transparent window -/// therefore has no swapchain to render into, and the frame reaches the screen -/// down the same readback-and-blit path a compositor that cannot back a Vulkan -/// swapchain already uses (`soft_present` in `src/gui/gui.zig`): one -/// GPU->CPU download plus one upload per PAINTED frame, measured at 1.2 ms for -/// 2240x1440 and 3.0 ms for 3840x2160 on an RTX 3050. Idle frames cost -/// nothing — this shell only paints when something changed — but an animation -/// at 60 Hz spends that every frame. -/// -/// With a theme that DOES bring a background this changes nothing visible: the -/// ground is painted at full alpha, exactly as an opaque window would. It -/// still pays the readback, because window transparency is fixed at creation -/// and a `Theme` command may reach a transparent theme later. +// Transparency requires the SDL readback/blit path, even with an opaque theme. pub const gui_transparent: bool = false; -/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be -/// vertical carries a little sideways drift, and the pad faithfully turns that -/// drift into wheel_left/wheel_right — so a plain scroll slides the view -/// sideways under you. Every vertical tick re-arms the guard to this many -/// ticks and every horizontal tick spends one instead of scrolling, which -/// makes horizontal EARN its way back: it has to land this many ticks in a row -/// with no vertical among them. 3, because drift arrives in ones and twos — -/// at 1 or 2 a doubled drift tick mid-swipe still gets through, and much -/// higher starts eating deliberate swipes. Set 0 to disable the heuristic. -/// -/// It costs nothing at rest: the guard is only armed by vertical scrolling, so -/// a horizontal swipe that starts from a still view moves on its FIRST tick. -/// Note this applies to a tilt wheel too, where "recent vertical" is a much -/// weaker signal of accident — a mouse would rather not have it. Living with -/// that is deliberate: the only honest fix is a per-device flag out of the -/// shell (libinput/SDL know which is which, vaxis does not), and paying for a -/// device-detection layer to spare a tilt wheel three clicks after a scroll is -/// a worse trade than the three clicks. +// A vertical wheel tick suppresses this many subsequent horizontal ticks. pub const wheel_guard_ticks: u8 = 3; -/// The whole guard, as one state machine, so it can be tested as one thing: -/// fold a wheel tick into `guard` and answer whether it scrolls. The core owns -/// the counter (Pardes.wheel_guard) because the gesture belongs to the DEVICE, -/// not to whichever pane the pointer happens to sit over. pub fn wheelTick(guard: *u8, vertical: bool) bool { if (vertical) { guard.* = wheel_guard_ticks; @@ -685,107 +280,53 @@ pub fn wheelTick(guard: *u8, vertical: bool) bool { return false; } -// written to hold for ANY tuning of wheel_guard_ticks, since tuning it by hand -// is what this file is for — a test that pinned the number 3 would just be a -// second place to edit it test "wheel drift guard" { var g: u8 = 0; - // from rest, horizontal moves on the first tick — nothing to prove try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); if (wheel_guard_ticks == 0) return; // guard disabled: nothing left to check - // a vertical swipe with drift mixed in: every sideways tick is swallowed, - // because each vertical tick re-arms the guard in full for (0..4) |_| try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); - // the deliberate horizontal swipe that follows pays off the rest of the - // guard tick by tick, then runs free for (1..wheel_guard_ticks) |_| try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); } -// ---- layout numbers that are taste ---- - -/// What a terminal pane runs until someone says otherwise (the Shell builtin, -/// or a `Shell ` line in the config file). A bare NAME, resolved against -/// the usual bin directories at spawn time — so a machine without it falls -/// back rather than opening a pane that dies at exec. pub const default_shell = "fish"; -/// the file pane's line-number gutter, in columns +// Minimum file gutter width, including the space after the line number. pub const PREFIX_W: u16 = 5; -/// Display width of a literal tab in every Surface-backed frontend. This is a -/// compile-time setting: edit it and rebuild; zero cannot advance the renderer. pub const tab_width: u16 = 4; comptime { if (tab_width == 0) @compileError("config.tab_width must be greater than zero"); } -/// soft wrap (the Wrap builtin): the glyph a wrapped row ends with, in the one -/// column bodyText keeps free for it. A break is the one thing about a wrapped -/// line you cannot see — the text simply continues, and a missing line number -/// on the row below is an absence, which is a poor thing to read a document by. -/// So the break says so at the point it happens, in the chrome's own colour -/// because it is not in the file. pub const wrap_marker = "↩"; -/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible -/// above/below the cursor (clamped at file boundaries and short panes), and -/// the same count of COLUMNS horizontally +// Cursor motion preserves context in both rows and columns when space permits. pub const scroll_off = 3; -/// the pane's left chrome: scrollbar + the layout box in the tag row. The -/// scrollbar PAINTS only the first of these columns; the second is the pane's -/// own background, so the bar reads as one column with a column of page -/// between it and the text. Layout is untouched by that — the gutter is still -/// GUTTER columns and a click anywhere in them still scrolls; only the ink -/// narrowed. A half-block glyph in the second column was tried and rejected. pub const GUTTER: u16 = 2; -/// a pane never shrinks past this (the h-handle can still take it to its tag -/// row alone, which is BOX_H and a structural fact, not this) pub const MINW: u16 = 10; pub const MINH: u16 = 3; -// ============================================================================ -// PART 2 — Look/Exec syntax: how a click on text is SPELLED. What the -// resolution then DOES with it is look.zig. -// ============================================================================ - -/// file-ish word chars (acme's isfilec): alnum + these. This set is the whole -/// definition of "the word under the cursor" for Look, Execute, the tag chord -/// and every search result row. +// Accept every UTF-8 byte so word boundaries never split a codepoint. pub fn isFileChar(c: u8) bool { - // Non-ASCII bytes belong to their UTF-8 word as a unit. Bounds are byte - // offsets, so accepting every high byte keeps Unicode paths/identifiers - // intact instead of returning a slice through one codepoint. return c >= 0x80 or std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } -/// `` @`ls -la` `` — a word that names a COMMAND to run rather than a file to -/// open. Both halves are named here because they are a CHOICE: the `@` marks -/// it as ours (it is already a file char, so it can never split a path) and -/// the backquotes hold a command line with spaces in it, which is the whole -/// point — a file-ish word cannot. Respell them here and nowhere else. pub const cmd_open = "@`"; pub const cmd_close: u8 = '`'; -/// The command inside `` @`...` ``, or null when `w` is not one. pub fn commandWord(w: []const u8) ?[]const u8 { if (w.len <= cmd_open.len or !std.mem.startsWith(u8, w, cmd_open)) return null; if (w[w.len - 1] != cmd_close) return null; return w[cmd_open.len .. w.len - 1]; } -/// The bounds of the word at `col` in `line` — THE expansion a no-drag -/// look/execute click and the tag chord both use. -/// -/// A `` @`...` `` run is taken WHOLE and wins outright: a backtick is not a -/// file char, so the plain scan below would stop dead inside one and hand a -/// look the fragment `ls` out of `` @`ls -la` ``. Acme does exactly this for -/// its own `<`/`|`/`>` command words. Otherwise it is the file-ish word. +// A complete command word wins over the ordinary file-character scan. pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } { var i: usize = 0; while (std.mem.indexOfPos(u8, line, i, cmd_open)) |o| { @@ -800,58 +341,19 @@ pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } return .{ .lo = lo, .hi = hi }; } -/// separates a path from its LINE and COL: `main.zig:100:7`. Must be a member -/// of isFileChar or the suffix would not be part of the word in the first -/// place. pub const line_col_sep: u8 = ':'; -/// ...and separates that spot from the END of a RANGE. A look at a ranged path -/// SELECTS the span rather than just parking on its first cell, which is what -/// lets a search result carry the text it matched and `n` land ON it. -/// -/// Three spellings. The long one subsumes the other two, but the short ones -/// are what a person actually types and what a grep-alike emits, so all three -/// parse: -/// main.zig:412-418 lines 412 through 418, whole -/// main.zig:412:9-21 line 412, columns 9 through 21 -/// main.zig:412:9-418:1 line 412 column 9 through line 418 column 1 -/// Both ends are INCLUSIVE and 1-based, like the spot they extend — `412-418` -/// reads as seven lines, not six. `main.zig:412` and `main.zig:412:9` keep -/// meaning exactly what they always did. -/// -/// Must be an isFileChar member, same as the separator above, or a click would -/// expand to half a range. That is also why reading it is FUSSY (look.zig, -/// parsePathLine): ordinary paths are full of dashes, so the suffix counts as -/// a range only when a NUMBER follows the dash — `my-file:10` and `build-2` -/// stay the paths they are. +// Ranges are inclusive and 1-based: path:2-4, path:2:3-7, path:2:3-4:1. pub const range_sep: u8 = '-'; -/// `@p7:10:5` — pane 7, line 10, column 5. The one look target that names a -/// live pane instead of a path, because terminals and output buffers have no -/// file for a location to point at. Both the writer (a `/` result row) and the -/// reader (look.resolve) spell it from here. +// @p7:10:5 addresses pane 7, line 10, column 5. pub const pane_addr = "@p"; -/// a word starting with one of these is a URL and leaves the app entirely: no -/// filesystem can answer it pub const url_schemes = [_][]const u8{ "http://", "https://" }; -/// a path ending in one of these opens an image pane instead of a file pane pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; -/// What `Ctrl-c` (comment_toggle) puts at the front of a line, by file -/// EXTENSION — which is how src/syntax.zig already tells one language from -/// another, so this is that same notion and not a second one. A pane whose -/// path matches nothing here (and every terminal, which has no path at all) -/// gets `comment_token_default`. -/// -/// `#` as the default is not a guess: it is helix's own DEFAULT_COMMENT_TOKEN, -/// which is what a helix buffer with no language configured comments with — -/// and therefore what the differential oracle answers, since the harness runs -/// with zero language configs. -/// Languages with no LINE comment at all (css, html, json, ocaml) are absent -/// on purpose: helix leaves those buffers on its default too, and inventing a -/// token for them would be a divergence nothing asked for. +// Match Helix's fallback for unknown languages and languages without line comments. pub const comment_token_default = "#"; pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{ .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".typ", ".typst", ".swift", ".dart" } }, @@ -863,37 +365,16 @@ pub const comment_tokens: []const struct { exts: []const []const u8, token: []co .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } }, }; -/// What an armed search writes into the tag tail — and the ONLY record of -/// which search it is: Enter reads the marker back (submitSearch) instead of -/// pardes carrying a second piece of pane state per command. The pattern is -/// everything past the `/`, so a pattern may itself contain slashes; the word -/// before it is the builtin's own name, so the armed tag reads as the command -/// it will run. The bare `/` has no name — it searches the pane itself. pub const search_marker = " /"; pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; -/// Save on a pane with no file of its own yet — an output buffer or a terminal: -/// the tail is the whole PATH to write (no `/` separator, since a path is made -/// of them), prefilled with the pane's directory so only a filename need be -/// typed. pub const save_marker = " Save "; -/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there -/// is no Select/Split command to run from a tag, they name the key that armed -/// the input so the tag still reads as what it is about to do. They also mark -/// the one input that previews as you type (pardes.zig, previewSelRegex). pub const select_marker = " Select /"; pub const split_marker = " Split /"; -/// The `|` prompt is not an executable tag word: the marker only makes the -/// pending shell filter visible, and everything after it is preserved as the -/// exact command passed to `/bin/sh -c`. pub const pipe_marker = " |"; -/// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a -/// buffer is DERIVED from the command that opened it (output_pane.traits), and -/// nothing identifies one by matching this text any more — renaming any of -/// these changes only what you read in a tag. pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; pub const config_buffer = "+Config"; @@ -906,62 +387,25 @@ pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; pub const messages_buffer = "+Messages"; -/// What `9p ` opens a remote file into. NOT the remote path: an -/// output buffer's name comes off the command that filled it, and the path is -/// the command's ARGUMENT, which is what makes two remote files two panes. -pub const ninep_buffer = "+9p"; -/// The two memory windows a bare-metal build's Peek and Hexdump render. Absent -/// from every hosted build along with the builtins that name them. pub const peek_buffer = "+Peek"; pub const hexdump_buffer = "+Hexdump"; pub const gpio_buffer = "+Gpio"; -/// The empty buffer New and Newcol open: no file behind it yet, so Save asks -/// for a path (prefilled with the inherited directory). pub const scratch_buffer = "+New"; -/// acme's own `+Errors`, and the one output buffer no keystroke opens: a -/// script writes it, through a pane's `errors` file or the top-level `cons` -/// (src/acmefs.zig). pub const errors_buffer = "+Errors"; -// ============================================================================ -// PART 3 — THE HELIX KEYMAP. READ THIS BEFORE RETARGETING ANYTHING BELOW. -// -// These are not free choices. `zig build hxdiff` (481 cases) and -// `zig build hxparity` (561 cases) are DIFFERENTIAL suites: they drive a real -// helix and this core with the same keystrokes and compare the results, and a -// mismatch is a failure, not a diff to accept. Moving a key here therefore -// breaks the build until the divergence is written down as a waiver with a -// reason — which is the correct workflow for a DELIBERATE divergence (Alt-c -// above is one) and an alarm for an accidental one. -// -// Everything in PART 1 is outside that contract and free to move. -// ============================================================================ - -// ---- modal prefixes ---- - -// These are STORED — pane.pending / pending2 / find_op hold the codepoint -// ITSELF until the next key completes the sequence, and the continuation reads -// it back — so they are bare codepoints rather than Chords, and pardes.zig -// matches them with `isPrefix` instead of `hit`. A prefix must therefore be an -// unmodified printable key. Untyped so they compare against both the u21 and -// the u8 fields that hold them. +// Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; pub const prev_prefix = '['; -// f/F/t/T: the stored byte IS the operator — `f`/`t` mean forward and `t`/`T` -// mean stop short — so the four are read back as values, not just matched. pub const find_char_fwd = 'f'; pub const find_char_back = 'F'; pub const till_char_fwd = 't'; pub const till_char_back = 'T'; -/// repeat the last f/F/t/T pub const repeat_find: []const Chord = &.{.{ .cp = '.', .alt = true }}; -// ---- motion ---- - pub const move_left: []const Chord = &.{ .{ .cp = 'h' }, .{ .cp = Key.left } }; pub const move_right: []const Chord = &.{ .{ .cp = 'l' }, .{ .cp = Key.right } }; pub const move_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; @@ -975,31 +419,21 @@ pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; -/// helix goto_line: only acts WITH a count (bare G is a no-op; `ge` is -/// goto-last-line) +// Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; -/// grapheme motion in a ONE-LINE context (a tag, the topbar): the arrows only. -/// A tagline spends h/l on the layout (window_keys) and the topbar answers -/// them itself (topbar_left/right), so the letters are not in this vocabulary. pub const line_move_left: []const Chord = &.{.{ .cp = Key.left }}; pub const line_move_right: []const Chord = &.{.{ .cp = Key.right }}; -// ---- paging and the view ---- - pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const page_down: []const Chord = &.{ .{ .cp = 'f', .ctrl = true }, .{ .cp = Key.page_down } }; pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Key.page_up } }; -/// under `z`: put the cursor's line at the top / centre / bottom of the view pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -/// under `z`: scroll the view one line, cursor snapped to the scrolloff edge pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; -// ---- under `g` (goto) ---- - pub const goto_file_start: []const Chord = &.{.{ .cp = 'g' }}; pub const goto_last_line: []const Chord = &.{.{ .cp = 'e' }}; pub const goto_line_start: []const Chord = &.{.{ .cp = 'h' }}; @@ -1008,43 +442,27 @@ pub const goto_first_nonws: []const Chord = &.{.{ .cp = 's' }}; pub const goto_line_down: []const Chord = &.{.{ .cp = 'j' }}; pub const goto_line_up: []const Chord = &.{.{ .cp = 'k' }}; pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; -/// view-relative rows (helix goto_window) pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -// helix's five LSP gotos, all under `g` and nowhere else. They are motions, -// not builtins — a motion has no business being a word you can middle-click, -// which is why they are not in the language group under `SPC l`. pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; pub const goto_implementation: []const Chord = &.{.{ .cp = 'i' }}; pub const goto_references: []const Chord = &.{.{ .cp = 'r' }}; -// ---- under `m` (match) ---- - -/// `mm` acts at once, so it is an ordinary chord pub const match_bracket: []const Chord = &.{.{ .cp = 'm' }}; -// The other five are SUB-prefixes: each waits for a textobject or surround -// character (`mi(`, `mr[{`), so pardes stores them the way it stores `m` and -// they are bare codepoints for the same reason as the block above. pub const match_inside = 'i'; pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; -// ---- under `]` / `[` ---- - pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; -/// step the diagnostics list, asking for one if it is not up yet pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; -/// ]D / [D — the last / the first pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; -// ---- insert entry ---- - pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; pub const insert_line_start: []const Chord = &.{.{ .cp = 'I' }}; @@ -1052,8 +470,6 @@ pub const insert_line_end: []const Chord = &.{.{ .cp = 'A' }}; pub const open_below: []const Chord = &.{.{ .cp = 'o' }}; pub const open_above: []const Chord = &.{.{ .cp = 'O' }}; -// ---- selection ---- - pub const select_mode: []const Chord = &.{.{ .cp = 'v' }}; pub const select_line: []const Chord = &.{.{ .cp = 'x' }}; pub const select_line_bounds: []const Chord = &.{.{ .cp = 'X' }}; @@ -1062,13 +478,6 @@ pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }}; pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }}; pub const select_all: []const Chord = &.{.{ .cp = '%' }}; -// ---- multiple cursors ---- -// -// helix's Selection is a LIST of ranges with a primary index, and these ten -// keys are the ones that act on the list rather than on the text: every other -// key is replayed once per range instead (pardes.zig, replaySels). Alt-C is -// Alt-SHIFT-c and so does not collide with pane_to_new_column's Alt-c — `hit` -// compares the codepoint, and `C` is `C`. pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }}; pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }}; pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }}; @@ -1080,22 +489,9 @@ pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }}; pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }}; pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; -// The other two list-making keys: a REGEX turns each range into many. Both -// arm the tag input above (select_marker / split_marker) instead of doing -// anything immediately, so `s` and `S` are the only normal-mode keys whose -// effect lands a keystroke later, on Enter — or live, as you type. -// `s` is free here despite `gs` (goto_first_nonws) also being `s`: a pending -// prefix is matched by the stored codepoint, never by these chords. pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; -// ---- edits ---- -// -// Every one of these reads or writes the DEFAULT register and only that. -// The system clipboard is five separate words on `SPC y Y p P R`, which is -// helix's split and the reason `d` cannot silently eat what you copied out of -// a browser. See leader_path above and builtins.zig's clipboard section. - pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; pub const change: []const Chord = &.{.{ .cp = 'c' }}; @@ -1109,35 +505,720 @@ pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; -/// helix's format_selections — its neighbour on the keyboard and in the keymap pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; pub const undo: []const Chord = &.{.{ .cp = 'u' }}; pub const redo: []const Chord = &.{.{ .cp = 'U' }}; -/// helix `toggle_comments`: comment or uncomment every line the selection -/// touches, with `comment_tokens` above choosing the token. Ctrl-c reaches a -/// terminal pane only in NORMAL mode — raw tty forwards it to the program, -/// where it is still SIGINT. pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; -/// In body normal mode, clear modal residue and run Last (the same builtin as -/// `SPC j j`): the pane you were in before this one, whichever it was. Held -/// down it alternates between two panes — two files, or a file and its shell. -/// Elsewhere: leave insert mode; abandon a leader path, tag, armed search or -/// the topbar; raw tty mode forwards it to the program. +// Body-normal Esc runs Last; other modes cancel input or leave insert mode. pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; -// ---- insert mode ---- - -// The three helix aliases: normalized to the base key and re-dispatched, so -// they behave identically to it everywhere downstream. pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; -/// helix insert-mode kills. Ctrl-w is also the WINDOW prefix in normal/tty — -/// insert mode wins it, which is helix's own arrangement. pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const kill_to_line_end: []const Chord = &.{.{ .cp = 'k', .ctrl = true }}; + +pub const Runtime = struct { + theme: usize = 0, + colors: bool = true, + wrap: bool = true, + tag_bottom: bool = false, + debug: bool = false, + + // Effective values change only after a host acknowledges the request. + shell: struct { + requested: Text(255) = .{}, + effective: Text(limits.host_path_cap) = .{}, + pending: bool = true, + } = .{}, + + font: struct { + requested_path: Text(limits.host_path_cap) = .{}, + requested_name: Text(255) = .{}, + effective_name: Text(255) = .{}, + pending: bool = false, + effective_size_hundredths: u16 = 0, + effective_size_unit: FontSizeUnit = .unknown, + tagline_percent: u8 = 100, + } = .{}, + + panel_transition: layout.Transition = .off, + scene_effects: layout.SceneEffect = .{}, + + pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void { + std.debug.assert(effect != .off); + state.panel_transition = if (state.panel_transition == effect) .off else effect; + } + + pub const tagline_percent_min: u8 = 1; + pub const tagline_percent_max: u8 = 100; + + pub fn Text(comptime capacity: usize) type { + return struct { + bytes: [capacity]u8 = @splat(0), + len: std.math.IntFittingRange(0, capacity) = 0, + + pub fn get(value: *const @This()) []const u8 { + return value.bytes[0..value.len]; + } + + pub fn set(value: *@This(), text: []const u8) bool { + if (text.len > capacity) return false; + @memcpy(value.bytes[0..text.len], text); + value.len = @intCast(text.len); + return true; + } + + pub fn clear(value: *@This()) void { + value.len = 0; + } + }; + } + + pub const FontSizeUnit = enum { unknown, pixels, points }; + + // Validate both strings before changing either member of the request. + pub fn requestFont(state: *Runtime, path: []const u8, name: []const u8) bool { + if (path.len > state.font.requested_path.bytes.len or + name.len > state.font.requested_name.bytes.len) return false; + std.debug.assert(state.font.requested_path.set(path)); + std.debug.assert(state.font.requested_name.set(name)); + state.font.pending = true; + return true; + } + + pub const Capabilities = struct { + font_picker: bool, + panel_transitions: bool, + scene_shaders: bool, + tagline_font_size: bool, + }; + + pub const Capability = std.meta.FieldEnum(Capabilities); + + pub const Toggle = enum { colors, wrap, tag_bottom, debug }; + pub const Scene = std.meta.FieldEnum(layout.SceneEffect); + + pub const Action = union(enum) { + toggle: Toggle, + shell, + theme, + font, + tagline_size, + transition: layout.Transition, + scene: Scene, + }; + + pub const Setting = struct { + word: []const u8, + action: Action, + availability: ?Capability = null, + + pub fn takesArg(setting: Setting) bool { + return switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }; + } + + pub fn enabled(setting: Setting, capabilities: Capabilities) bool { + const capability = setting.availability orelse return true; + return switch (capability) { + inline else => |field| @field(capabilities, @tagName(field)), + }; + } + }; + + // The builtin registry and Config report share this command table. + pub const settings = [_]Setting{ + .{ .word = "Colors", .action = .{ .toggle = .colors } }, + .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, + .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, + .{ .word = "Debug", .action = .{ .toggle = .debug } }, + .{ .word = "Theme", .action = .theme }, + .{ .word = "Shell", .action = .shell }, + .{ .word = "Font", .action = .font, .availability = .font_picker }, + .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, + .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, + .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, + .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, + .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, + .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, + .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, + .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, + .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, + .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, + .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, + .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, + .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, + .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, + .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, + }; + + pub fn find(name: []const u8) ?Setting { + for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; + return null; + } + + pub fn findAction(action: Action) ?Setting { + for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; + return null; + } + + fn actionCount(comptime action: Action) comptime_int { + var count = 0; + for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); + return count; + } + + comptime { + @setEvalBranchQuota(20_000); + for (settings, 0..) |setting, i| { + if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); + for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) + @compileError("duplicate runtime setting command word: " ++ setting.word); + switch (setting.action) { + .font, .tagline_size => if (setting.availability != .font_picker) + @compileError("native font settings must use the font-picker capability"), + .transition => if (setting.availability != .panel_transitions) + @compileError("panel effects must use the panel-transition capability"), + .scene => if (setting.availability != .scene_shaders) + @compileError("scene effects must use the scene-shader capability"), + else => if (setting.availability != null) + @compileError("unconditional settings cannot carry a backend capability"), + } + } + for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) + @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); + if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or + actionCount(.tagline_size) != 1) + @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); + for (std.enums.values(layout.Transition)) |effect| { + const expected: comptime_int = @intFromBool(effect != .off); + if (actionCount(.{ .transition = effect }) != expected) + @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); + } + for (std.enums.values(Scene)) |effect| { + if (actionCount(.{ .scene = effect }) != 1) + @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); + if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool) + @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); + } + } + + pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { + switch (setting.action) { + .toggle => |field| switch (field) { + .colors => state.colors = !state.colors, + .wrap => state.wrap = !state.wrap, + .tag_bottom => state.tag_bottom = !state.tag_bottom, + .debug => state.debug = !state.debug, + }, + .shell => { + const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (value.len == 0 or !state.shell.requested.set(value)) return false; + state.shell.pending = true; + }, + .tagline_size => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < tagline_percent_min or percent > tagline_percent_max) return false; + state.font.tagline_percent = @intCast(percent); + }, + .transition => |effect| state.toggleTransition(effect), + .scene => |effect| switch (effect) { + inline else => |field| { + const value = &@field(state.scene_effects, @tagName(field)); + value.* = !value.*; + }, + }, + .theme, .font => return false, + } + return true; + } + + // Slices are borrowed for one writeReport call. + pub const ReportContext = struct { + startup_config_path: ?[]const u8, + platform: []const u8, + theme_name: []const u8, + compiled_default_shell: []const u8, + gui_shader_source_mode: ?[]const u8 = null, + hover_delay_frames: ?u16, + native_images: bool, + capabilities: Capabilities, + state: *const Runtime, + }; + + fn onOff(value: bool) []const u8 { + return if (value) "on" else "off"; + } + + fn shown(text: []const u8) []const u8 { + return if (text.len == 0) "(none)" else text; + } + + fn transitionSettingName(transition: layout.Transition) []const u8 { + if (transition == .off) return "off"; + return findAction(.{ .transition = transition }).?.word; + } + + pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { + const state = context.state; + var wrote_transition = false; + for (settings) |setting| switch (setting.action) { + .toggle => |field| { + const value = switch (field) { + .colors => state.colors, + .wrap => state.wrap, + .tag_bottom => state.tag_bottom, + .debug => state.debug, + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + }, + .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .shell => { + const chosen = state.shell.requested.get(); + try out.print( + "{s} requested (new panes): {s}{s}\n" ++ + "{s} effective (last spawn): {s}\n" ++ + "{s} pending: {s}\n", + .{ + setting.word, + if (chosen.len == 0) context.compiled_default_shell else chosen, + if (chosen.len == 0) " (default)" else "", + setting.word, + shown(state.shell.effective.get()), + setting.word, + onOff(state.shell.pending), + }, + ); + }, + .font => if (!setting.enabled(context.capabilities)) + try out.print("{s}: unsupported\n", .{setting.word}) + else + try out.print( + "{s} requested: {s}\n" ++ + "{s} requested path: {s}\n" ++ + "{s} effective: {s}\n" ++ + "{s} pending: {s}\n" ++ + "{s} effective size: {d}.{d:0>2} {s}\n", + .{ + setting.word, + shown(state.font.requested_name.get()), + setting.word, + shown(state.font.requested_path.get()), + setting.word, + shown(state.font.effective_name.get()), + setting.word, + onOff(state.font.pending), + setting.word, + state.font.effective_size_hundredths / 100, + state.font.effective_size_hundredths % 100, + @tagName(state.font.effective_size_unit), + }, + ), + .tagline_size => if (!context.capabilities.tagline_font_size) + try out.print("{s}: unsupported\n", .{setting.word}) + else if (!setting.enabled(context.capabilities)) + try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + else + try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + .transition => { + if (wrote_transition) continue; + wrote_transition = true; + if (!setting.enabled(context.capabilities)) + try out.writeAll("Panel transition: unsupported\n") + else + try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + }, + .scene => |effect| { + if (!setting.enabled(context.capabilities)) { + try out.print("{s}: unsupported\n", .{setting.word}); + continue; + } + const enabled = switch (effect) { + inline else => |field| @field(state.scene_effects, @tagName(field)), + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + }, + }; + + if (context.startup_config_path) |path| + try out.print("Startup config: {s}\n", .{path}) + else + try out.writeAll("Startup config: no per-user config path\n"); + try out.print( + "Platform: {s}\n" ++ + "Compiled default shell: {s}\n", + .{ context.platform, context.compiled_default_shell }, + ); + if (context.gui_shader_source_mode) |mode| + try out.print("GUI shader source: {s}\n", .{mode}); + if (context.hover_delay_frames) |frames| + try out.print("Look hover delay: {d} frames\n", .{frames}) + else + try out.writeAll("Look hover delay: off\n"); + try out.print("Native images: {s}\n", .{onOff(context.native_images)}); + } + + test "setting names are unique and argument metadata follows actions" { + for (settings, 0..) |setting, i| { + try std.testing.expect(setting.word.len > 0); + for (settings[i + 1 ..]) |later| + try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); + try std.testing.expectEqual(switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }, setting.takesArg()); + } + } + + test "simple setting application mutates only its plain field" { + var state: Runtime = .{}; + try std.testing.expect(apply(&state, find("Colors").?, null)); + try std.testing.expect(!state.colors); + try std.testing.expect(apply(&state, find("Shell").?, " fish\n")); + try std.testing.expectEqualStrings("fish", state.shell.requested.get()); + try std.testing.expect(state.shell.pending); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.off, state.panel_transition); + try std.testing.expect(apply(&state, find("Crt").?, null)); + try std.testing.expect(state.scene_effects.crt); + } + + test "tagline size validates before mutating live state" { + const setting = find("TaglineSize").?; + var state: Runtime = .{}; + + for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { + try std.testing.expect(apply(&state, setting, argument)); + try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); + } + + state.font.tagline_percent = 67; + for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { + try std.testing.expect(!apply(&state, setting, argument)); + try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); + } + } + + test "font request tuple rejects atomically" { + var state: Runtime = .{}; + try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); + var too_long: [256]u8 = @splat('x'); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); + try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); + try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); + } + + test "Config report observes every simple setting and all live context" { + var state: Runtime = .{}; + var storage: [4096]u8 = undefined; + const context: ReportContext = .{ + .startup_config_path = "/tmp/pardes/init", + .platform = "gui", + .theme_name = "acme", + .compiled_default_shell = "/bin/sh", + .gui_shader_source_mode = "live GLSL compiled during this build", + .hover_delay_frames = 18, + .native_images = true, + .capabilities = .{ + .font_picker = true, + .panel_transitions = true, + .scene_shaders = true, + .tagline_font_size = true, + }, + .state = &state, + }; + + for (settings) |setting| { + switch (setting.action) { + .theme, .font => continue, + else => {}, + } + const argument: ?[]const u8 = switch (setting.action) { + .shell => "fish", + .tagline_size => "73", + else => null, + }; + try std.testing.expect(apply(&state, setting, argument)); + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + const expected = switch (setting.action) { + .toggle => |field| switch (field) { + .colors => "Colors: off\n", + .wrap => "Wrap: off\n", + .tag_bottom => "Tagbottom: on\n", + .debug => "Debug: on\n", + }, + .shell => "Shell requested (new panes): fish\n", + .tagline_size => "TaglineSize: 73%\n", + .transition => |transition| switch (transition) { + .off => unreachable, + .slide => "Panel transition: PanelSlide\n", + .zoom => "Panel transition: PanelZoom\n", + .dissolve => "Panel transition: PanelDissolve\n", + .ascii => "Panel transition: PanelAscii\n", + .vertical => "Panel transition: PanelVertical\n", + .edges => "Panel transition: PanelEdges\n", + .fall => "Panel transition: PanelFall\n", + .wave => "Panel transition: PanelWave\n", + .curtain => "Panel transition: PanelCurtain\n", + .scramble => "Panel transition: PanelScramble\n", + .typewriter => "Panel transition: PanelType\n", + }, + .scene => |effect| switch (effect) { + .crt => "Crt: on\n", + .ripple => "Ripple: on\n", + .glitch => "Glitch: on\n", + }, + .theme, .font => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + } + + try std.testing.expect(state.font.requested_name.set("Wanted Mono")); + try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); + try std.testing.expect(state.font.effective_name.set("Effective Mono")); + state.font.pending = true; + state.font.effective_size_hundredths = 1375; + state.font.effective_size_unit = .points; + state.font.tagline_percent = 82; + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + for ([_][]const u8{ + "Theme: acme\n", + "Font requested: Wanted Mono\n", + "Font requested path: /fonts/wanted.ttf\n", + "Font effective: Effective Mono\n", + "Font pending: on\n", + "Font effective size: 13.75 points\n", + "TaglineSize: 82%\n", + "Startup config: /tmp/pardes/init\n", + "Platform: gui\n", + "Compiled default shell: /bin/sh\n", + "GUI shader source: live GLSL compiled during this build\n", + "Look hover delay: 18 frames\n", + "Native images: on\n", + }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + + var defaults: Runtime = .{}; + var defaults_context = context; + defaults_context.startup_config_path = null; + defaults_context.platform = "tty"; + defaults_context.gui_shader_source_mode = null; + defaults_context.hover_delay_frames = null; + defaults_context.native_images = false; + defaults_context.capabilities = .{ + .font_picker = false, + .panel_transitions = true, + .scene_shaders = false, + .tagline_font_size = false, + }; + defaults_context.state = &defaults; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const defaults_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); + + defaults_context.platform = "web"; + defaults_context.capabilities.panel_transitions = false; + defaults_context.capabilities.tagline_font_size = true; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const web_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); + } +}; + +pub const User = struct { + const max_bytes = 1024 * 1024; + + pub const init_name = "init"; + pub const builtin_themes_subdir = "themes/builtin"; + + // Relative XDG_CONFIG_HOME values are ignored. + pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { + if (builtin.os.tag == .windows) { + if (env.get("LOCALAPPDATA")) |base| if (base.len != 0) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + if (env.get("USERPROFILE")) |home| if (home.len != 0) + return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); + return null; + } + + if (env.get("XDG_CONFIG_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base)) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + if (builtin.os.tag == .macos) + return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); + return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); + } + + // The caller's allocator owns these slices, including paths when init is absent. + pub const Found = struct { + dir: ?[]const u8 = null, + path: ?[]const u8 = null, + bytes: ?[]const u8 = null, + }; + + pub fn load( + io: std.Io, + gpa: std.mem.Allocator, + env: *const std.process.Environ.Map, + ) Found { + const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; + const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; + return .{ + .dir = config_dir, + .path = config_path, + .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, + }; + } + + test "config path honors XDG and rejects a relative XDG directory" { + if (builtin.os.tag == .windows) return; + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("HOME", "/home/pardes-test"); + try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); + + const xdg = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(xdg); + try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); + + try env.put("XDG_CONFIG_HOME", "relative/config"); + const fallback = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(fallback); + const expected = if (builtin.os.tag == .macos) + "/home/pardes-test/Library/Application Support/pardes" + else + "/home/pardes-test/.config/pardes"; + try std.testing.expectEqualStrings(expected, fallback); + } + + test "config loader reads init inside the config directory" { + if (builtin.os.tag == .windows) return; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); + + const missing = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(missing.dir.?); + defer std.testing.allocator.free(missing.path.?); + const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); + defer std.testing.allocator.free(expected_dir); + const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); + defer std.testing.allocator.free(expected); + try std.testing.expectEqualStrings(expected_dir, missing.dir.?); + try std.testing.expectEqualStrings(expected, missing.path.?); + try std.testing.expect(missing.bytes == null); + const source = "Theme dark\nUnknown command\nTheme acme\n"; + try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); + const found = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(found.dir.?); + defer std.testing.allocator.free(found.path.?); + defer std.testing.allocator.free(found.bytes.?); + try std.testing.expectEqualStrings(expected_dir, found.dir.?); + try std.testing.expectEqualStrings(source, found.bytes.?); + } + + // Replace generated theme files; preserve unrelated user files. + pub fn dumpThemes( + io: std.Io, + gpa: std.mem.Allocator, + config_dir: []const u8, + theme_values: anytype, + ) ![]u8 { + const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); + errdefer gpa.free(out_dir); + try std.Io.Dir.cwd().createDirPath(io, out_dir); + + for (theme_values) |theme_value| { + var encoded: std.Io.Writer.Allocating = .init(gpa); + defer encoded.deinit(); + try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); + + const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); + defer gpa.free(filename); + const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); + defer gpa.free(output_path); + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = output_path, + .data = encoded.written(), + }); + } + return out_dir; + } + + test "theme dump creates the builtin subdirectory and ZON files" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(io, &base_buf); + const Sample = struct { name: []const u8, rgb: [3]u8 }; + const samples = [_]Sample{ + .{ .name = "one", .rgb = .{ 1, 2, 3 } }, + .{ .name = "two", .rgb = .{ 4, 5, 6 } }, + }; + const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); + defer gpa.free(output); + const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); + defer gpa.free(expected); + try std.testing.expectEqualStrings(expected, output); + + const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); + defer gpa.free(one_path); + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); + defer gpa.free(bytes); + const source = try gpa.dupeZ(u8, bytes); + defer gpa.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); + defer std.zon.parse.free(gpa, parsed); + try std.testing.expectEqualStrings("one", parsed.name); + try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); + } +}; + +test { + _ = Runtime; + _ = User; +} diff --git a/src/crash.zig b/src/crash.zig index d9c47715..10d38591 100644 --- a/src/crash.zig +++ b/src/crash.zig @@ -26,7 +26,7 @@ const pardes = @import("pardes.zig"); /// Beside `init`, so `Config` opens the directory that holds both. pub const name = "crashes"; -/// The config directory `user_config.load` resolved, COPIED rather than +/// The config directory `config.User.load` resolved, COPIED rather than /// borrowed: main.zig hands over an arena slice that outlives the process, but /// the AppKit host's lives in a `config_arena` its own `errdefer` frees on a /// failed init and its teardown frees at quit — and a panic after either would diff --git a/src/detached/client.zig b/src/detached/client.zig index f317b2e2..0350ecf7 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -40,7 +40,7 @@ //! DISPLAY: a daemon nobody is looking at has no clipboard and no browser. //! The answer to `read_clipboard` is not a reply message: it is an ordinary //! `Event.paste` sent back through `send`, which is the same asynchronous -//! shape `pull_read_clipboard` already has in-process. +//! shape `read_clipboard` already has in-process. //! * `refuse` is followed by the session closing the connection, and `quit` //! means the session itself has ended. //! The switch in `next` is exhaustive over that set on purpose: putting a @@ -481,26 +481,7 @@ pub fn resolve(buf: *[server.path_max]u8, requested: []const u8) Resolved { return .{ .name = buf[0..len] }; } -/// How long a frontend's attached loop waits on the socket before it goes back -/// to whatever else it owns. It lives HERE, beside the `wait` it parameterises, -/// because both frontends need it and both had defined it for themselves — -/// which is how a measured number drifts from the thing it was measured -/// against. -/// -/// A frontend cannot hand `poll(2)` one descriptor for the session and one for -/// its own input: vaxis delivers the terminal's events on a reader thread into -/// a mutex/condvar queue, and SDL has its own pump, so neither has a -/// descriptor. `wait` takes a timeout for exactly that reason. -/// -/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it -/// is on the wire (4 ms on average), and the frame it causes needs no wait at -/// all — it lands in the poll the moment the session writes it. The price is -/// 125 poll rounds a second on a frontend nobody is touching, measured below -/// the noise of what an idle pardes already costs: on an i7-11700 at 100 Hz -/// jiffies an idle attached frontend used 0.16% of one core over 60 s and 0.18% -/// over 120 s, against 0.11% and 0.31% for an idle in-process session on the -/// same screen over the same windows. Reach for an eventfd and a waker thread — -/// fuse.zig's `pollLoop` is the pattern — only if that stops being true. +// Input arrives through frontend queues, so attached clients bound socket waits to half a frame. pub const poll_ms: u32 = 8; /// One round of waiting for the greeting, and how many of them. The COUNT is @@ -541,22 +522,8 @@ pub const Attempt = union(enum) { lost: anyerror, }; -/// Resolve a name, connect to it, and WAIT FOR THE WELCOME. On every failure -/// path this closes whatever it opened, so a caller that gets anything but -/// `.greeted` has nothing to clean up. -/// -/// The waiting is the point, and it is why this function exists rather than -/// each frontend calling `resolve` and `open` in turn. `open` is not a -/// handshake — it connects and writes the hello, and the `welcome` or the -/// `refuse` arrives later through this loop. A frontend that treats a -/// successful `connect(2)` as proof of attachment will tear its local session -/// down — reap its pane shells, unmount its control filesystem, free every -/// undo history — and only then discover `refuse .version`, which is the -/// routine case: `zig build` replaces the binary under a running session, so -/// two protocol versions on one machine is expected rather than exotic. The -/// contract the `Attach` word owes is that a failed attach changes NOTHING, and -/// that contract can only be kept by a caller that has the welcome in hand -/// before it starts destroying things. +/// Wait for welcome before the caller replaces its session; connect alone can +/// still lead to a version refusal. Every non-greeted result owns no resources. pub fn attempt(gpa: std.mem.Allocator, requested: []const u8, cols: u16, rows: u16) Attempt { var buf: [server.path_max]u8 = undefined; const name = switch (resolve(&buf, requested)) { @@ -648,7 +615,16 @@ const Harness = struct { errdefer h.arena.deinit(); // `io` is not optional on `Session`: the daemon does the file watching // and the theme scan itself now, and both of those take a `std.Io`. - h.session = .{ .gpa = testing.allocator, .io = std.testing.io, .core = h.core, .cols = cols, .rows = rows }; + h.session = .{ + .gpa = testing.allocator, + .worker_gpa = testing.allocator, + .io = std.testing.io, + .core = h.core, + .cols = cols, + .rows = rows, + }; + try h.session.initAsync(); + errdefer h.session.deinit(); h.name = "s"; try testing.expect(h.session.listen(h.name)); // The pre-loop drain tty.zig has, for its reason: the startup spawns are @@ -683,12 +659,12 @@ const Harness = struct { /// for hosts whose worker threads post from off the loop. fn pump(h: *Harness) !void { const host = h.session.host(); - host.vtable.pull_wait_input.?(host.ctx, 20); + host.vtable.wait_input.?(host.ctx, 20); while (h.core.nextEffect()) |e| h.core.perform(e); if (h.core.quit) return; _ = h.arena.reset(.retain_capacity); const surface = try h.core.render(h.arena.allocator()); - host.vtable.push_present.?(host.ctx, surface); + host.vtable.present.?(host.ctx, surface); } /// The round budget every `pumpUntil*` below shares. A round moves at most @@ -833,6 +809,71 @@ test "detached session: input from a frontend reaches the core and comes back as try h.pumpUntilShowsCore(&c); } +test "detached Restore keeps attached frontends and follows queued frames with a full replacement" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + _ = try h.core.setTestFile("saved body\n"); + var c = try h.attach(60, 16); + defer c.deinit(); + _ = try h.pumpUntil(&c, .frame); + try h.pumpUntilShowsCore(&c); + + const before = h.core; + const fd = h.session.clients[c.slot].fd; + try testing.expectError(error.BadDumpMagic, h.session.restore( + ".{ .magic = \"not-a-pardes-dump\", .theme = \"dark\", .screen = .{ .cols = 60, .rows = 16 } }", + )); + try testing.expectEqual(before, h.session.core); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + + try h.core.dumpState(); + const saved = try testing.allocator.dupe(u8, h.core.dump_out.?); + defer testing.allocator.free(saved); + while (h.core.nextEffect()) |_| {} + _ = try h.core.setTestFile("changed after dump\n"); + try h.session.restore(saved); + h.core = h.session.core; + try testing.expect(h.core != before); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + try testing.expectEqualStrings("saved body\n", h.core.panes[0].?.file.?.content); + try testing.expect(h.session.clients[c.slot].need_full); + var wake = [_]libc.pollfd{.{ .fd = h.session.mailbox.wake[0], .events = poll_in, .revents = 0 }}; + try testing.expectEqual(@as(c_int, 1), libc.poll(&wake, wake.len, 0)); + for (0..Harness.rounds) |_| { + if ((try h.pumpUntil(&c, .frame)).frame.kind == .full) break; + } else return error.NoFullReplacement; + try h.pumpUntilShowsCore(&c); +} + +test "detached selection pipe runs off the loop and returns through the attached frontend" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + const pane = try h.core.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + var client = try h.attach(60, 16); + defer client.deinit(); + _ = try h.pumpUntil(&client, .frame); + + try client.send(.{ .event = .{ .key = .{ .cp = '|' } } }); + try client.send(.{ .event = .{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } } }); + try client.send(.{ .event = .{ .key = .{ .cp = pardes.Key.enter } } }); + for (0..Harness.rounds) |_| { + try h.pump(); + try client.wait(5); + while (try client.next()) |_| {} + if (std.mem.eql(u8, pane.file.?.content, "ONE\ntwo\n")) break; + } else return error.PipeDidNotComplete; + try testing.expect(h.core.pipe_wait == null); + try testing.expectEqual(@as(usize, 0), h.session.pipe_tasks.len); + try h.pumpUntilShowsCore(&client); +} + test "detached session: two frontends share one screen at the smallest common grid" { var h: Harness = undefined; try h.init(80, 24); @@ -1031,26 +1072,21 @@ test "detached session: the seam's own routing rules, per surviving effect" { const host = h.session.host(); // BROADCAST: the yank register is a fact about the session, so every // display it is being watched on gets it. - host.vtable.push_set_clipboard.?(host.ctx, "yank"); + host.vtable.set_clipboard.?(host.ctx, "yank"); try expectBoth(&h, &a, &b, .set_clipboard); - // ORIGIN, ELSE PRIMARY. This is the "only one frontend is asked" rule that - // the shell-forking and file-writing messages used to demonstrate; the - // daemon does that work itself now, so the same claim is made about the two - // effects that still travel. `read_clipboard` is asked ONCE — two frontends - // answering would paste twice for one Ctrl-V, which is the rule host.zig - // states. + // Ask one frontend: two clipboard answers would paste twice. h.session.origin = 1; - host.vtable.pull_read_clipboard.?(host.ctx); + host.vtable.read_clipboard.?(host.ctx); try expectOnly(&h, &b, &a, .read_clipboard); // `open_link` follows the same origin: the browser that opens is the one on // the display of the human who clicked, not the oldest attachment's. - host.vtable.push_open_link.?(host.ctx, "https://x"); + host.vtable.open_link.?(host.ctx, "https://x"); try expectOnly(&h, &b, &a, .open_link); // ...and with no origin it falls back to the primary, which is what a link // opened by something other than a keystroke gets. h.session.origin = 0; - host.vtable.push_open_link.?(host.ctx, "https://y"); + host.vtable.open_link.?(host.ctx, "https://y"); try expectOnly(&h, &a, &b, .open_link); } @@ -1185,17 +1221,14 @@ test "detached session: a frontend that stops reading is dropped, not waited for } try testing.expect(h.session.clients[1].attached); - // Broadcast enough control traffic to pass `out_backlog`. A clipboard - // mirror is the honest vehicle: it is a real `push_` that reaches every - // frontend and carries the yank register, so this is a session yanking a - // lot rather than a synthetic poke. + // Clipboard updates broadcast to every frontend. const text = try testing.allocator.alloc(u8, 256 * 1024); defer testing.allocator.free(text); @memset(text, 'y'); const host = h.session.host(); for (0..24) |_| { if (!h.session.clients[1].attached) break; - host.vtable.push_set_clipboard.?(host.ctx, text); + host.vtable.set_clipboard.?(host.ctx, text); // Read `good` back to EMPTY before the next mirror, rather than // pumping once and taking whatever one write fitted. One pump moves at // most one socket buffer, and that buffer is 8 KiB here diff --git a/src/detached/server.zig b/src/detached/server.zig index db93e2e4..8b49bcb2 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1,617 +1,306 @@ -//! THE DETACHED CORE: one `Pardes` instance in a process with no terminal, -//! serving N frontends over one unix socket. -//! -//! THIS SIDE OWNS THE CORE, AND EVERYTHING UNDER IT. `Session` is a `host.Host` -//! implementation that performs the machine-local half of a host itself — it -//! forks the pane shells, writes the files, watches the paths — and whose -//! `pull_wait_input` is one `poll(2)` over the listener, every attached -//! frontend, every pane's pty master and the inotify descriptor. A frontend owns -//! a screen and a keyboard and nothing else (client.zig). So the `Pardes` is -//! here, `update` is called from here, the shells are forked from here, and the -//! same screen is on every attached frontend at once — `screen -x`, not N -//! sessions. -//! -//! WHAT THIS SIDE SERVES ITSELF, WHICH IS NOW ALL OF IT. A unix socket means -//! the core and its frontends are on the SAME machine, so there is no question -//! of whose process table, whose disk or whose inotify descriptor a call is -//! about — and given that, the process that must hold them is the long-lived -//! one. A shell forked by a frontend dies with that frontend, and a session -//! whose whole promise is outliving the frontend attached to it cannot keep its -//! panes that way. So this file forks the pane shells (`host_io.forkShell`), -//! writes the files (`host_io.writeFileBytes`), marks the directories -//! (file_watch.zig) and drains the pty masters in its own `poll(2)`. THE PANE -//! SHELLS OUTLIVE EVERY FRONTEND: attach, detach, kill the terminal, attach -//! from another one, and the build that was running in pane 3 is still running -//! and has been scrolling into the core the whole time. -//! -//! Only what this vtable leaves null falls through to the core's own -//! `host.Fallback` — and host.zig says in as many words that a zero-method host -//! is a complete pardes. What a frontend can still do BETTER is exactly what -//! needs the human's own display, and nothing else: put a yank on the clipboard -//! in front of them, take a paste off it, open a link in their browser. Three -//! messages, which is why the routing table below is as short as it is. -//! -//! ROUTING, and it is not "push means broadcast". A push reaches every HOST -//! (host.zig's rule, which `Fanout.isPull` enforces); this is ONE host that -//! happens to be backed by several frontends, and how it spreads a call inside -//! itself is its own business. Two rules over four messages: -//! * BROADCAST — the frame, and `set_clipboard`. Every screen must show the -//! same thing, and a yank in a shared session is a session-wide fact that -//! every attached desktop is entitled to. -//! * ORIGIN, ELSE PRIMARY — `read_clipboard` (the one `pull_` on the wire), -//! `open_link`, and `detach`. Each answers a thing a HUMAN just did, and the -//! answer belongs to that human: the paste must come from the keyboard that -//! asked for it, a link must open in front of the person who clicked it, and -//! a `Detach` typed in one frontend must send THAT frontend away and leave -//! the others painting. `origin` is the frontend whose event was applied -//! most recently. Effects drain after a whole batch of events (pardes.zig -//! `pump`), so in the rare case where two frontends type in the same -//! millisecond the second one wins; the fallback to `primary` — the lowest -//! attached slot, i.e. the oldest surviving attachment, a rule that is -//! stable while frontends come and go and needs no election — covers an -//! effect that no input caused at all. -//! -//! `detach` is the odd one and is worth naming as such: it is not an EFFECT the -//! session performs on the world, it is SESSION CONTROL — one frontend asking to -//! stop being a frontend. That is why wire.zig gives it 0x05, in the -//! 0x01..0x0f session range beside `quit`, rather than a number in the 0x10.. -//! range where every tag is one `push_` method that reaches a disk, a clipboard -//! or a browser. And it is why this side does nothing but send it: see `detach`. -//! There is no third rule, and the class of message it used to serve is gone: -//! `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`, `watch_file`, -//! `watch_theme` and `dump_themes` were routed to ONE frontend precisely -//! because each has one real resource behind it, and every one of them is now -//! performed HERE, once, by the process that owns the resource. Two frontends -//! can no longer fork two shells for pane 3 or race each other writing one -//! path, because neither of them writes anything. -//! -//! FAIRNESS, and why no client — and no shell — can stall the core or another -//! client. The property the bullets below add up to is worth stating as one -//! sentence, because it is what a detached session is FOR: there is no path on -//! which this process blocks indefinitely. Every descriptor it holds is -//! non-blocking, the single `poll(2)` is the only place it sleeps, and every -//! queue that could grow without bound has a ceiling with a stated answer for -//! reaching it. A daemon nobody is looking at cannot be made to stop looking -//! after the shells nobody else is keeping. -//! * ONE `poll(2)` per pump covers the listener, all `max_clients` frontends, -//! all `pardes.MAX_PANES` pty masters and the inotify descriptor: -//! `poll_slots` descriptors, one syscall, no thread per client and none per -//! pty. Putting the shells in the poll set the clients were already in is -//! what lets a daemon own sixteen of them and stay single-threaded. -//! * one read per pty per round, which is `receive`'s rule for clients -//! applied to shells: a `yes` in pane 1 gets one turn and the loop moves on -//! to the other panes, the frontends and the frame. -//! * EVERY descriptor is non-blocking, sockets and pty masters alike, and a -//! pane owes its bytes the same way a client does. A blocking write to a -//! master was the one hole this file's own comment used to argue was safe — -//! "the peer on a pty is a shell this process forked, not a stranger who can -//! stop reading on purpose" — and that was wrong, because the peer is -//! whatever program the human ran in that pane. `sleep 3600` plus a paste -//! larger than the pty's input buffer parked the WHOLE daemon inside -//! `write(2)`: no frame to any frontend, fifteen other masters unread, no -//! `accept`, no `expire`, no inotify drain. So a pane has an out-queue and a -//! POLLOUT, on the descriptor that was already in the set. See `ptyWrite`. -//! * FRAMES ARE NOT QUEUED. A client with bytes still owed to the kernel is -//! SKIPPED for this frame and its mirror is left alone, so the next frame -//! it does get is a diff against what it actually has. A slow frontend -//! therefore sees fewer, larger frames instead of a growing queue, and -//! coalescing costs no byte surgery at all. -//! * what is left in a client's out-queue is control messages, and it is -//! capped (`out_backlog`). The cap is checked BEFORE an append, so a single -//! oversized message still goes out whole and what gets refused is a client -//! that has stopped draining: it is closed. Its session and its peers are -//! untouched, and it may reattach and be sent a full frame. -//! * `max_clients` is a REFUSAL, not a queue — the same shape and the same -//! number as fuse.zig's park table, and for the same reason: the listener -//! is always accepted from even when the table is full, because a -//! level-triggered `poll` on a backlog nobody accepts returns ready -//! forever and spins a core. Bounded per round all the same (`accept`), and -//! a connection that never says `hello` loses its slot -//! (`greet_deadline_ms`) — a slot held by silence is the same denial as a -//! queue, arrived at from the other end. -//! * the TABLE is accounted, not just each client (`session_backlog`), and a -//! drained client gives its buffers back (`idle_retain`): 32 slots each -//! holding one 4 MiB paste is 128 MiB of a daemon nobody is looking at. -//! -//! THE SOCKET follows nested.zig's conventions exactly, and they ARE -//! nested.zig's: `socketDir`, `ensureSocketDir`, `statNoFollow` and -//! `setCloexec` are imported from it rather than copied, because one directory -//! vetted by two predicates is how the two go out of step. `$XDG_RUNTIME_DIR` -//! else `~/.local/state/pardes` created 0700 and vetted (never /tmp), -//! `chmod 0600` before `listen(2)`, CLOEXEC on the listener and on every -//! accepted connection. The NAME differs on purpose: -//! `pardes-detached-.sock` rather than `pardes-.sock`, so that -//! nested.zig's sweeper — which only recognises all-digit pids — never unlinks -//! a live detached session, and so that a person can say `--detach=work` -//! instead of learning a pid. -//! -//! WHO MAY BIND A NAME, and this side is not allowed to guess. `bind(2)` on a -//! unix socket is an atomic exclusive create, so it decides: a name whose -//! socket ANSWERS is a live session and `listen` refuses rather than taking it -//! (an unconditional unlink-before-bind is how a second `--detach=work` used -//! to steal the socket out from under every frontend attached to the first). -//! The only file this process unlinks is one it proved dead — a connect that -//! was REFUSED — and `alive` is the single place that judgement is made, for -//! `listen` and for the sweep both. -//! -//! ...and both ends do the vetting. `vetted` is the frontend's half: a socket -//! at a path anyone could plant receives every keystroke that frontend -//! collects, so the client checks the directory and the socket before it -//! connects, exactly as this side checks them before it binds. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const posix = std.posix; const pardes = @import("../pardes.zig"); -const host_api = @import("../host.zig"); const wire = @import("wire.zig"); -/// The machine-local half of a host — fork a shell onto a pty, put bytes on a -/// disk — shared verbatim with the tty shell, and the sharing is the point: -/// `spawn` and `writeFile` below are the same two operations tty.zig performs, -/// and having them in one file is what keeps a daemon's pane and a terminal's -/// pane the same pane. See host_io.zig's header for why the daemon is the side -/// that performs them. const host_io = @import("../host_io.zig"); +const selection_pipe = @import("../selection_pipe.zig"); -/// ...and the inotify half, likewise shared: `applyEffect` is the mark-then- -/// reconcile transaction the tty and sdl shells run, and this session runs the -/// identical one. All that differs is who waits on the descriptor — a thread -/// there, `waitInput`'s poll set here. const file_watch = @import("../file_watch.zig"); -/// acme's control filesystem, which a detached session had no way to serve -/// until now: `push_fs_reply` was the one machine-local effect this host left -/// null, so a script could drive a tty or an SDL session and not a daemon — -/// the configuration whose whole promise is outliving the terminal. -/// -/// It costs less here than it does in the desktop shells. They need a thread -/// blocked on `poll()` to notice a request and wake their loop -/// (`fs_service.wake`); this process already owns a `poll(2)` over everything -/// else it waits on, so `/dev/fuse` is one more descriptor in that set and -/// there is no thread at all. `Source.fuse` is the arm; `pollFrame` is the -/// drain, at the same point in the frame that tty.zig drains. -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); -/// ...and the SECOND transport onto that same tree, on a unix socket of its -/// own. `Source.ninep_listener` and `Source.ninep` are its arms; `pollFrame` -/// is its drain, beside the mount's, at the same point in the frame. -const fs9_service = @import("../fs9_service.zig"); - -/// Host-lifetime storage for the OSC 133 rc files a forked shell sources, held -/// by `Session` because a Session is exactly one host's lifetime. -const shell_bin = @import("../shell_bin.zig"); - -/// `shellCwd` for a pane's shell, `ttyTaken` for a pane the core is about to -/// type a command line into, `readFile` for `run`'s `--load`. -const look = @import("../look.zig"); +const ninep_io = @import("../9p_io.zig"); -/// The "saved " / "dumped themes " message row, stamped the way -/// every other host stamps it — one clock format across every frontend. -const message = @import("../message.zig"); +const look = @import("../look.zig"); -/// `Dump themes` writes the reference set out as .zon, into the core's own -/// `opts.config_dir`. -const user_config = @import("../user_config.zig"); +const message = pardes.Pardes.Message; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). The -// same constant the tty, gui and macos shells spell, for the same reason. const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); -/// Diagnostics for whoever is running the daemon. Every one of these is a -/// `debug`, and the level is not a judgement about how bad the thing is: -/// main.zig's logFn drops this scope entirely unless PARDES_LOG is set, so what -/// decides whether a human sees it is that variable and not the level. Reaching -/// for `warn` instead would change exactly one thing — a TEST binary does not -/// go through logFn, and its stderr is the build runner's failure signal. const log = std.log.scoped(.detached); -/// nested.zig owns the socket conventions this file shares — the directory, -/// its vetting, the stat that will not follow a symlink, CLOEXEC — and its -/// module comment carries the reasoning for each. Imported and not copied: -/// see the module header. -const nested = @import("../nested.zig"); - -/// `pub` for client.zig, which needs the same platform answer for the same -/// reason: SIGPIPE is per-write on linux and per-socket on darwin. -pub const darwin = nested.darwin; - -/// Same two ingredients as nested.zig needs, minus the ancestor walk: unix -/// sockets and a per-user runtime directory. Anywhere else there is no detached -/// session and `listen` says so. -const supported = nested.supported; - -/// `sun_path` is 108 bytes on linux and 104 on darwin, taken from the struct so -/// that the buffers, the fit checks and the memcpy cannot disagree with the -/// kernel or with each other. -const sun_path_len = nested.sun_path_len; - -/// How many frontends may be attached at once. The number and the shape are -/// fuse.zig's park table: 32 slots, and overflow is a refusal rather than a -/// queue. A session with 32 frontends on it is not a session, it is a mistake, -/// and the 33rd gets told so instead of waiting in a backlog nobody drains. +pub const darwin = ninep_io.darwin; + +const supported = ninep_io.supported; + +const sun_path_len = ninep_io.sun_path_len; + pub const max_clients = 32; -/// Bytes of un-drained CONTROL messages a client may owe before it is closed. -/// Frames are not in here (see the module header), so this bounds a backlog of -/// the three things that are still on the wire — a welcome, a clipboard mirror, -/// a link to open — and a frontend that has not taken 1 MiB of those has -/// stopped reading its socket. Checked before an append rather than after, so -/// one oversized message is never the thing that trips it. const out_backlog = 1 << 20; -/// One read per client per poll round (see `receive`). 16 KiB is two orders of -/// magnitude past a keystroke and small enough to sit on the loop's stack; a -/// 4 MiB paste arrives across several rounds, which is the point. const read_chunk = 16 * 1024; -/// Bytes taken off one pane's pty per poll round. 64 KiB is what every other -/// host's pty reader uses (`readPty` in tty.zig, gui.zig and macos.zig), and it -/// sits on `readPty`'s own frame rather than the loop's. Nothing is copied out -/// of it: `Event.output` borrows the buffer for one `update` call, so a daemon -/// serving a shell that is printing a build log asks the allocator for nothing. const pty_chunk = 64 * 1024; -/// Descriptors in the ONE poll this process runs: the listener, every frontend, -/// every pane's pty master, the inotify descriptor behind every watch, -/// `/dev/fuse`, the 9P listener, and every 9P connection. That is -/// 1 + 32 + 16 + 1 + 1 + 1 + 4 = 56 on a full house, and one syscall covers -/// all of them. -const poll_slots = 1 + max_clients + pardes.MAX_PANES + 2 + 1 + fs9_service.max_conns; +const poll_slots = 2 + max_clients + pardes.MAX_PANES + 2 + 2 + @as(usize, @intFromBool(ninep_io.quic_enabled)) + ninep_io.max_conns; -/// How many times `reloadWatched` will honour `file_watch.reloadChanged`'s -/// request for another pass within one round. See `reloadWatched`. const reload_retries = 4; -/// Bytes of client traffic — every in-queue and out-queue together — this -/// session may hold before it starts closing the peers holding it. -/// `out_backlog` bounds ONE slot and this bounds the table, which is not the -/// same ceiling: a client's `out` tops out at `out_backlog` plus the one -/// oversized message allowed through whole, so `out_backlog` alone permits -/// 32 * (1 + 1.6) MiB, about 83 MiB of a daemon nobody is looking at. -/// -/// DERIVED, and the derivation IS the fix. This was the literal `4 << 20`, -/// which was by coincidence the exact value of tty.zig's `max_paste_bytes` — -/// and `in` grows to hold one WHOLE message, so a frontend assembling the very -/// paste wire.zig names as one of the two messages that set `max_payload` -/// crossed the table's ceiling while still receiving it. The session then -/// closed the only frontend it had, mid-paste, with `.backlog`, which is the -/// diagnostic for a peer that STOPPED reading. The documented maximum paste -/// could not complete. Two whole `max_payload`s is the smallest number that is -/// headroom rather than another coincidence: one peer may legitimately be -/// assembling a message of the largest size `framed` will accept while the rest -/// of the table holds frames, and past 32 MiB the fattest peer is the peer that -/// stopped draining. Neither the mirrors nor the pane queues are in this -/// number: a mirror is this session's own bookkeeping for a client it chose to -/// serve, and a pane is bounded per pane by `pty_backlog` because it is not a -/// peer and cannot be closed to reclaim anything. const session_backlog = 2 * @as(usize, wire.max_payload); -/// Bytes of un-drained INPUT one pane's shell may owe before more is refused. -/// -/// A pane is not a client, so the answer cannot be `out_backlog`'s: a client -/// that stops draining is closed, and the thing at the other end of a pty is a -/// program the human is running. This refuses the write and says so on the -/// pane's message row instead, which is the only honest answer left — dropping -/// input silently loses half a command line, and killing a shell to reclaim a -/// megabyte destroys work. -/// -/// Checked BEFORE the append, exactly as `queue` checks `out_backlog`, and that -/// is what makes 1 MiB enough: any single write lands whole, so a maximum paste -/// into an empty queue is never truncated. What gets refused is MORE input typed -/// at a program that has stopped reading its input at all — `sleep 3600`, a -/// stopped job, anything blocked on its own output. const pty_backlog = 1 << 20; -/// How long a connection has to say `hello`, and the ONE number both ends of -/// this transport time the handshake against. `pub` because a frontend that -/// waited longer than the session is willing to hold its slot would report a -/// timeout for a slot that had already been taken back, and a frontend that -/// waited less would give up on a session that was still going to answer — two -/// halves of one deadline, and two literals is how they drift apart. -/// -/// The `Session` field it initialises is a field and not this constant for -/// exactly one reason: the test for expiry would otherwise have to sleep five -/// seconds. See `Session.greet_deadline_ms`. pub const greet_deadline_default_ms: u32 = 5_000; -/// What a DRAINED client is allowed to keep. `in` grows to hold one whole -/// message, so a single 4 MiB paste otherwise leaves 4 MiB resident in that -/// slot for the life of the session — 128 MiB across a full table, for -/// something that happened once. Anything above one `read_chunk` is handed -/// back the moment the buffer empties, and the next message pays one -/// allocation for it; below that it is kept, so a session of keystrokes never -/// asks the allocator at all. const idle_retain = read_chunk; -/// How long the listener is left out of the poll set after an `accept` that -/// failed for a reason that persists (EMFILE above all). See `accept`: the -/// alternative was sleeping 100 ms inside the core. const accept_pause_ms = 100; -/// Why a client's connection ended. Only ever logged (`PARDES_LOG=1`), and -/// spelled out because "connection closed" is the one diagnostic that has never -/// helped anybody. const Closed = enum { bye, peer, protocol, backlog, silent, write, read, oom, refused, quitting }; const Client = struct { fd: c_int = -1, - /// The `hello` landed and was accepted. Before that the connection exists - /// but votes on nothing and is sent no frames: its geometry is unknown. attached: bool = false, - /// A `welcome` is owed, and is sent once this round's geometry has settled - /// so the number in it is the one the next frame will use. greet: bool = false, - /// This frontend's own window, as its last `hello`/`resize` said. One vote - /// in `reconcile`'s minimum, never the session's grid by itself. cols: u16 = 0, rows: u16 = 0, - /// Bytes read and not yet a whole message. in: std.ArrayListUnmanaged(u8) = .empty, - /// Bytes owed to the kernel. out: std.ArrayListUnmanaged(u8) = .empty, - /// What this client's grid holds, so the next frame can be a diff. Advanced - /// only when a frame is actually queued for it, which is what makes a - /// skipped frame correct rather than lost. mirror: std.ArrayListUnmanaged(pardes.Cell) = .empty, - /// The next frame must be full: freshly attached, or the session geometry - /// moved under it. need_full: bool = true, - /// Monotonic milliseconds at `accept`, and the only thing an un-greeted - /// connection is timed against. See `Session.greet_deadline_ms`. accepted_ms: i64 = 0, }; -/// A pane's shell: forked by THIS process, drained by its poll set, reaped by -/// it. -/// -/// There is no owner here and nothing is owed, and the absence is the whole -/// change. This struct used to record which frontend had been asked to fork a -/// pane and re-ask the next arrival when that frontend left, because the pty -/// lived in the frontend that forked it; and a `--detach`, whose panes always -/// exist before its socket does, had nobody to ask at all and had to remember -/// the request instead. Both were one problem, and forking here dissolves both: -/// a startup layout's shells are forked during `run`'s pre-loop drain with -/// nobody attached, and they are still those same shells when the tenth -/// frontend attaches an hour later. const Pty = struct { - /// The pty master, non-negative exactly when this pane has a live shell. - /// While it is here it is in the poll set (`waitInput`), NON-BLOCKING like - /// every other descriptor this file holds — `spawn` flips it, because - /// `forkpty` hands it back blocking and tty.zig's streaming reader wants it - /// that way. fd: c_int = -1, - /// Kept past the fork for `look.shellCwd` and `look.ttyTaken`, both of which - /// ask /proc about this pid rather than about the descriptor. pid: posix.pid_t = 0, - /// Bytes owed to this shell's stdin, drained by POLLOUT and bounded by - /// `pty_backlog`. The same shape as `Client.out`, for the same reason: the - /// thing on the far side may not be reading, and this process must not wait - /// to find out. See `ptyWrite`. + kill_at: i64 = 0, out: std.ArrayListUnmanaged(u8) = .empty, }; -/// What one descriptor in `waitInput`'s poll set is. A tagged union rather than -/// the bare slot index this loop used to carry alongside its `pollfd`s, because -/// the set now holds four different kinds of thing and a `u8` cannot say which. +const RetiredShell = struct { pid: posix.pid_t = 0, kill_at: i64 = 0 }; + +const Completion = union(enum) { + lsp: struct { id: u32, rows: ?[]u8 }, + pipe: selection_pipe.Response, + + fn deinit(completion: *Completion, gpa: std.mem.Allocator) void { + switch (completion.*) { + .lsp => |result| if (result.rows) |rows| gpa.free(rows), + .pipe => |*result| result.deinit(gpa), + } + } +}; + +const Mailbox = struct { + const capacity = selection_pipe.Tasks.capacity + 1; + const Batch = struct { + items: [capacity]Completion = undefined, + len: usize = 0, + status: ?[]u8 = null, + }; + + mutex: std.atomic.Mutex = .unlocked, + batch: Batch = .{}, + wake: [2]c_int = .{ -1, -1 }, + + fn post(box: *Mailbox, completion: Completion) void { + while (!box.mutex.tryLock()) std.atomic.spinLoopHint(); + // Tasks retain their slot until the owner consumes their one completion. + std.debug.assert(box.batch.len < capacity); + box.batch.items[box.batch.len] = completion; + box.batch.len += 1; + box.mutex.unlock(); + box.signal(); + } + + fn signal(box: *Mailbox) void { + while (libc.send(box.wake[1], "w", 1, nosignal) < 0) { + if (libc.errno(-1) != .INTR) break; + } + } + + fn take(box: *Mailbox) Batch { + var bytes: [128]u8 = undefined; + while (libc.recv(box.wake[0], &bytes, bytes.len, 0) > 0) {} + while (!box.mutex.tryLock()) std.atomic.spinLoopHint(); + defer box.mutex.unlock(); + const batch = box.batch; + box.batch.len = 0; + box.batch.status = null; + return batch; + } +}; + const Source = union(enum) { listener, + completion, client: u8, pty: u8, inotify, - /// The acme filesystem's descriptor. Its arm does one thing only: notice - /// that the connection has gone. Being in the set is otherwise the whole - /// point, because a readable `/dev/fuse` must END THE SLEEP so that - /// `pollFrame` — which runs after `pull_wait_input` returns — reaches the - /// drain. - /// - /// The drain is not done HERE, and the reason is not re-entrancy: both - /// `pull_wait_input` and `push_poll_frame` are called from inside - /// `Pardes.pump`, so either would re-enter. It is that `dispatch` is - /// mid-iteration over the `fds[0..n]`/`src[0..n]` SNAPSHOT `waitInput` - /// built, and a filesystem request reaches `core.perform`, which drains the - /// whole effect ring — including a `push_spawn`, whose `Session.spawn` - /// closes a pane's master and forks a new one. A later `.pty` entry in the - /// same pass would then apply the old descriptor's `revents` to a brand new - /// one, and the `fd < 0` guard cannot see it because the fd is valid, - /// merely different. Same hazard as the client slots, same reason. - fuse, - /// The 9P listener, and one arm per connection on it. Same shape and same - /// hazard as `fuse` above, and stated separately only because the hazard - /// is WORSE here: a 9P `Twrite` to `ctl` reaches `core.perform` exactly as - /// a FUSE write does, so it can `push_spawn` and close a pane's master - /// under a later `.pty` entry in this same pass. So `dispatch` moves BYTES - /// — accept, read into `push`, drain `output` — and never serves a - /// request; `pollFrame` does the serving, after the snapshot is done with. ninep_listener, - /// A connection's index in `Listener.conns`. + ninep_quic, ninep: u8, }; pub const Session = struct { gpa: std.mem.Allocator, - /// The Io every filesystem read this host performs goes through: - /// file_watch.zig's reload of a changed pane, and `user_config.dumpThemes`. - /// Required and not optional — a session that owns the disk work cannot be - /// handed a null disk. + worker_gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, - /// -1 when nothing is bound: an unsupported platform, or a bind that - /// failed. A session with no listener is a session nobody can attach to, - /// which still runs. listener: c_int = -1, - /// The bound path, kept so teardown unlinks exactly what was created and - /// nothing else — guarded on the fd, like nested.zig's `unlisten`. path_buf: [sun_path_len]u8 = undefined, path_len: usize = 0, clients: [max_clients]Client = @splat(.{}), - /// The session grid: the smallest common one across attached frontends. - /// Seeded from the core's own startup size so the first attach of an - /// identically sized frontend posts no resize at all. cols: u16, rows: u16, - /// Whose input was applied last, for the two calls that must go back to one - /// particular frontend. See the module header. origin: ?u8 = null, - /// One encode buffer, reused. Grown to whatever the largest message so far - /// needed rather than sized from `wire.max_payload`, which would be 16 MiB - /// of resident memory for a session whose frames are six kilobytes. scratch: std.ArrayListUnmanaged(u8) = .empty, - /// Each pane's shell. Forked here, drained by the poll set, reaped by - /// `harvest`. See `Pty`. ptys: [pardes.MAX_PANES]Pty = @splat(.{}), - /// The OSC 133 rc files a forked shell sources, staged once for the life of - /// this host exactly as tty.zig stages them for the life of a terminal: - /// `shell_bin.resolve` hands a child pointers into these buffers and the - /// child holds them until it execs, so they must not live in a stack frame. - /// The default is the empty one, which `resolve` reads as "this shell gets - /// no prompt marks"; `run` supplies a staged one. - prompt_rcs: shell_bin.PromptRcs = .{}, - /// The one inotify descriptor behind every watch this session holds, and the - /// last member of the poll set. Opened lazily — see `inotify`. + retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + mailbox: Mailbox = .{}, + lsp_task: ?host_io.Lsp.Task = null, + pipe_tasks: selection_pipe.Tasks = .{}, + prompt_rcs: host_io.Shell.PromptFiles = .{}, inotify_fd: c_int = -1, - /// acme's control filesystem, or null when `--fs` was not asked for or the - /// mount failed. Owned here rather than by `run` so that `deinit` unmounts - /// on every path out, including the error ones. - fs: ?*fuse.Fs = null, - /// The last drain stopped at `max_batch` with requests still in the kernel. - /// Same role as `check_files`: nothing else will wake us, because no - /// acknowledgement has gone back, so the next round must not sleep. - fs_pending: bool = false, - /// The same tree on a unix socket, or null when `--fs9` was not asked for - /// or the bind failed. Owned here, like `fs`, so that `deinit` closes the - /// socket and unlinks its path on every way out. - ninep: ?*fs9_service.Listener = null, - /// A 9P drain stopped with work still owed. `fs_pending`'s twin, and it - /// needs its own field rather than sharing that one: they are cleared by - /// different drains, and or'ing them into one flag would make a busy 9P - /// script keep the FUSE drain's `pending` set forever. + ninep: ?*ninep_io.Listener = null, ninep_pending: bool = false, - /// WHICH TRANSPORT THE REQUEST BEING SERVED CAME FROM, for the whole of - /// one `fs_service.drain` and never outside one. - /// - /// A filesystem reply reaches its transport through `push_fs_reply`, which - /// is a HOST method: the core answers a `fs_req` and does not know, and - /// must not know, that this process has two filesystems on one tree. This - /// field is the routing origin docs/9p.typ's layering table says a second - /// listener costs, and it is one pointer set by the drain that already - /// knows the answer. - /// - /// Null outside a drain, and `fsReply` then falls back to the mount. That - /// fallback is for a reply the core produced with no request outstanding, - /// which `Fs.reply` drops on a slot lookup; it is NOT a routing guess, and - /// a 9P reply cannot reach it — every 9P request is answered inside the - /// `step` that made it, which is inside the drain that set this. - fs_origin: ?fs_service.Transport = null, - /// Which directory mark belongs to which pane, and the generation the core - /// has already accepted from each. file_watch.zig owns the shape and the - /// transaction; this host owns only the descriptor and the wake. watches: file_watch.Table = @splat(null), - /// A reconcile pass is due: a watched directory had an edge, or the last - /// pass asked for another one. Consumed at the end of `waitInput`, which is - /// where a core change still makes the current frame — `Pardes.pump` renders - /// after `pull_wait_input` returns. check_files: bool = false, - /// False during `run`'s pre-loop effect drain. Read in exactly one place: - /// `Pardes.loadThemeFile` animates an interactive theme change and must not - /// animate a startup one, and a `ThemeFile` in a boot layout is a startup - /// one. tty.zig spells the same distinction `threads_ok`. in_loop: bool = false, - /// How long a connection may stay silent before the session takes its slot - /// back. `Client.open` writes its `hello` in the same call that connects, - /// so a peer that has said nothing for five seconds is not a frontend that - /// was slow, and thirty-two of them used to fill the table and lock every - /// real frontend out with a `refuse .full`. - /// - /// A field rather than a constant for exactly one reason: the test for that - /// would otherwise have to sleep five seconds. Nothing else changes it, and - /// the number itself is `greet_deadline_default_ms`, which the frontend half - /// of this transport reads too. greet_deadline_ms: u32 = greet_deadline_default_ms, - /// Monotonic milliseconds until which the LISTENER is left out of the poll - /// set, because an `accept` failed for a reason that persists. See `accept`. accept_paused_ms: i64 = 0, - // ---- lifetime --------------------------------------------------------- + pub fn initAsync(s: *Session) !void { + var pair: [2]c_int = undefined; + if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM, 0, &pair) != 0) return error.SocketFailed; + errdefer for (pair) |fd| { + _ = libc.close(fd); + }; + for (pair) |fd| { + if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.SocketOptionFailed; + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return error.SocketOptionFailed; + var options: libc.O = @bitCast(@as(u32, @bitCast(flags))); + options.NONBLOCK = true; + if (libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(options))))) < 0) + return error.SocketOptionFailed; + if (comptime darwin) { + const on: c_int = 1; + if (libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)) != 0) + return error.SocketOptionFailed; + } + } + s.mailbox.wake = pair; + pardes.lsp.setStatusSink(s, lspStatus); + } - pub fn deinit(s: *Session) void { - // First, and before the pane shells: a script blocked on `event` is - // holding a kernel request, and `Fs.deinit` answers everything still - // parked and ABORTS THE CONNECTION before unmounting, so that reader - // wakes with ENODEV while its own shell is still alive to run its exit - // path. After `closePty` it would be woken by a hangup instead, with - // nothing left to exit into. - // - // Not, as this comment first claimed, because the harvest takes time: - // `harvest` is `waitpid(WNOHANG)` in a loop and blocks for nothing. The - // one step here that CAN take milliseconds is this one, because - // `Fs.deinit` forks `fusermount3` and waits for it untimed — which is - // also why the `.quit` owed to every frontend now queues behind a - // subprocess. Worth knowing; not worth reordering, because the shells - // matter more than the milliseconds. - if (s.fs) |f| { - f.deinit(); - s.fs = null; + fn cancelWorkers(s: *Session) void { + if (s.lsp_task) |*task| { + task.future.cancel(s.io) catch {}; + s.lsp_task = null; + } + s.pipe_tasks.cancelAll(s.io); + _ = s.drainCompletions(false); + } + + fn drainCompletions(s: *Session, apply_results: bool) bool { + var batch = s.mailbox.take(); + for (batch.items[0..batch.len]) |*completion| { + defer completion.deinit(s.worker_gpa); + if (!apply_results) continue; + switch (completion.*) { + .lsp => |result| { + s.core.update(.{ .lsp_resp = .{ .id = result.id, .rows = result.rows } }); + if (s.lsp_task) |*task| if (task.id == result.id) { + task.future.await(s.io) catch {}; + s.lsp_task = null; + }; + }, + .pipe => |result| { + s.core.update(.{ .pipe_resp = .{ + .id = result.id, + .success = result.success, + .outputs = result.outputs, + .failure = result.failure, + } }); + s.pipe_tasks.finish(s.io, result.id); + }, + } + } + if (batch.status) |status| { + defer s.worker_gpa.free(status); + if (apply_results) { + var buf: [256]u8 = undefined; + s.core.setStatus(s.core.active, message.stamp(&buf, "lsp", status)); + } } - // ...and the 9P socket, for the mount's reason above: a script blocked - // on `event` over 9P is woken by the EOF its own connection closing - // produces, while its shell is still alive to run its exit path. The - // orphaned fids are not pumped — the core they would report releases to - // is going with them. + return batch.len != 0 or batch.status != null; + } + + pub fn restore(s: *Session, bytes: []const u8) !void { + const replacement = try s.core.restore(bytes); + s.cancelWorkers(); + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + s.harvest(); + for (0..pardes.MAX_PANES) |pane| + file_watch.watchPane(s.inotify_fd, &s.watches, @intCast(pane), null, 0, .{ .text = 0 }); + _ = file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, 0, false, false); + s.check_files = false; + if (s.ninep) |listener| listener.reset(s.core); + s.ninep_pending = false; + replacement.host = s.host(); + s.core.deinit(); + s.core = replacement; + for (&s.clients) |*client| if (client.attached) { + client.need_full = true; + }; + s.mailbox.signal(); + } + + pub fn deinit(s: *Session) void { + if (s.mailbox.wake[0] >= 0) pardes.lsp.setStatusSink(null, null); + s.cancelWorkers(); + for (s.mailbox.wake) |fd| if (fd >= 0) { + _ = libc.close(fd); + }; + s.mailbox.wake = .{ -1, -1 }; if (s.ninep) |l| { l.deinit(s.gpa); s.ninep = null; } - // Tell everyone the session is over before the socket disappears, so a - // frontend exits on a `quit` rather than on a read error whose meaning - // it has to guess. Best effort by construction: these descriptors are - // non-blocking, so a frontend that is not reading gets the EOF instead - // — which is a case it has to handle regardless. for (&s.clients) |*c| if (c.attached) s.send(c, .quit); for (&s.clients) |*c| if (c.fd >= 0) s.close(c, .quitting); s.unlisten(); s.scratch.deinit(s.gpa); - // The pane shells go with the SESSION and not with a frontend, which is - // this file's whole change. Closing a master is what hangs its shell up; - // `harvest` collects whatever has already exited, and the process is - // about to leave, so anything slower than that is the kernel's job. for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); - s.harvest(); - // Every mark dies with the descriptor, so there is nothing to unmark. + for (s.retired_shells) |shell| if (shell.pid > 0) { + _ = libc.kill(shell.pid, libc.SIG.KILL); + }; + for (s.ptys) |pty| if (pty.pid > 0) { + _ = libc.kill(pty.pid, libc.SIG.KILL); + }; + for (&s.retired_shells) |*shell| if (shell.pid > 0) { + while (libc.waitpid(shell.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + shell.* = .{}; + }; + for (&s.ptys) |*pty| if (pty.pid > 0) { + while (libc.waitpid(pty.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + pty.pid = 0; + }; if (s.inotify_fd >= 0) { _ = libc.close(s.inotify_fd); s.inotify_fd = -1; } - // Unlinks the two rc files staged for this host's shells. s.prompt_rcs.deinit(); } - /// Bind and listen. False when there is no socket, and a session without - /// one is simply one nobody can attach to — the same posture nested.zig - /// takes, and for the same reason: a failed bind must not cost a launch. pub fn listen(s: *Session, name: []const u8) bool { if (comptime !supported) return false; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return false; - if (!nested.ensureSocketDir(dir)) return false; + const dir = ninep_io.socketDir(&dir_buf) orelse return false; + if (!ninep_io.ensureSocketDir(dir)) return false; sweep(dir); const path = socketPath(&s.path_buf, dir, name) orelse return false; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return false; - nested.setCloexec(fd); - // `bind` IS the exclusive create — it fails with EADDRINUSE the moment - // the path exists — so it, and nothing else, decides who owns a name. - // There is no unlink before it: unlinking unconditionally is how a - // second `pardes --detach=work` took the socket away from a live - // session, leaving every frontend attached to a file no new frontend - // could reach. + ninep_io.setCloexec(fd); if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - // The one case that is not a collision: a session killed rather - // than quit ran no teardown, so its file outlived it. `alive` is - // the only thing that may say so, and it says so only about a - // connect that was REFUSED. if (alive(path)) { log.debug("a detached session is already listening on {s}", .{path}); _ = libc.close(fd); @@ -623,12 +312,7 @@ pub const Session = struct { return false; } } - // Owner-only, and BEFORE listen(2), which is the first moment anyone - // could connect. The directory is already private; this is the second - // wall, and this socket carries keystrokes into a live editor. _ = libc.chmod(path, 0o600); - // A backlog of max_clients: past that the kernel refuses the connect - // itself, which is the same answer `accept` would give. if (libc.listen(fd, max_clients) != 0) { _ = libc.close(fd); return false; @@ -643,15 +327,13 @@ pub const Session = struct { if (s.listener < 0) return; _ = libc.close(s.listener); s.listener = -1; - // Guarded on the fd, so a bind that FAILED cannot unlink a path this - // process never created. var z: [sun_path_len:0]u8 = undefined; @memcpy(z[0..s.path_len], s.path_buf[0..s.path_len]); z[s.path_len] = 0; _ = libc.unlink(z[0..s.path_len :0]); } - pub fn host(s: *Session) host_api.Host { + pub fn host(s: *Session) host_io.Host { return .{ .ctx = s, .vtable = &vtable }; } @@ -659,60 +341,97 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } - /// Eighteen methods, and NOT the fullest host in the tree — that claim - /// stood here, was believed, and was copied into docs/detached.md before an - /// audit counted the others. The tty and SDL shells fill TWENTY each - /// (everything but `pull_gpio_toggle` and `push_detach`) and macOS fifteen, - /// so this host is the only one that implements `push_detach` and otherwise - /// the least complete of the three desktop hosts. What is true is narrower - /// and is the point anyway: it performs every MACHINE-LOCAL effect there is, - /// and the four of host.zig's twenty-two it leaves null are null because - /// there is nothing here for them to do. Two of those four are real losses a - /// person can notice — no `pull_lsp` and no `pull_pipe`, because both want - /// the worker pool this deliberately single-threaded loop does not have. The - /// other two are not losses at all: `push_post_present` marks the moment a - /// frame reached a screen and this process has no screen, and - /// `pull_gpio_toggle` wants pads. - /// - /// `push_fs_reply` was the third real loss until this commit. It was null - /// because the daemon mounted no /dev/fuse, and the consequence was that a - /// detached session — the configuration whose whole promise is outliving the - /// terminal — was the one configuration no script could drive. It mounts one - /// now; see `fs` and `pollFrame`. - /// - /// `push_detach` is the one entry here that is not an effect. See `detach`. - const vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_poll_frame = pollFrame, - .push_spawn = spawn, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .push_detach = detach, - .push_fs_reply = fsReply, + const vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .poll_frame = pollFrame, + .spawn = spawn, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .detach = detach, + .lsp = lspRequest, + .pipe = pipeRequest, }; - // ---- routing ---------------------------------------------------------- + fn lspRequest(ctx: ?*anyopaque, request: host_io.Lsp.Request) void { + const s = of(ctx); + _ = s.drainCompletions(true); + if (s.lsp_task) |*task| { + task.future.cancel(s.io) catch {}; + s.lsp_task = null; + _ = s.drainCompletions(true); + } + const job = host_io.Lsp.snapshot(s.worker_gpa, s.core, request) catch |err| { + s.core.update(.{ .lsp_resp = .{ .id = request.id, .rows = null } }); + return s.core.reportError(request.pane, "lsp", err); + }; + const future = s.io.concurrent(lspWorker, .{ s.worker_gpa, job, &s.mailbox }) catch |err| { + job.free(s.worker_gpa); + s.core.update(.{ .lsp_resp = .{ .id = request.id, .rows = null } }); + return s.core.reportError(request.pane, "lsp", err); + }; + s.lsp_task = .{ .id = request.id, .future = future }; + } + + fn lspWorker(gpa: std.mem.Allocator, job: *host_io.Lsp.Job, box: *Mailbox) anyerror!void { + host_io.Lsp.work(gpa, job, box, deliverLspRows); + } + + fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const box: *Mailbox = @ptrCast(@alignCast(ctx.?)); + box.post(.{ .lsp = .{ .id = id, .rows = rows } }); + } + + fn lspStatus(ctx: ?*anyopaque, text: []const u8) void { + const s = of(ctx); + const copy = s.worker_gpa.dupe(u8, text) catch return; + while (!s.mailbox.mutex.tryLock()) std.atomic.spinLoopHint(); + if (s.mailbox.batch.status) |old| s.worker_gpa.free(old); + s.mailbox.batch.status = copy; + s.mailbox.mutex.unlock(); + s.mailbox.signal(); + } + + fn pipeRequest(ctx: ?*anyopaque, id: u32) void { + const s = of(ctx); + _ = s.drainCompletions(true); + if (s.pipe_tasks.full()) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return; + } + const request = s.core.pipeRequest(id) orelse return; + const job = selection_pipe.Job.copy(s.worker_gpa, request) catch |err| { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.worker_gpa, job, &s.mailbox }) catch |err| { + job.deinit(s.worker_gpa); + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); + } + + fn pipeWorker(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, box: *Mailbox) anyerror!void { + defer job.deinit(gpa); + box.post(.{ .pipe = selection_pipe.runJob(gpa, io, job) }); + } - /// The oldest surviving attachment. No election and no state: slots are - /// filled lowest-first, so the lowest attached one is the oldest that is - /// still here. fn primary(s: *Session) ?*Client { for (&s.clients) |*c| if (c.attached) return c; return null; } - /// ...and the frontend whose input we are answering, when there is one. fn origins(s: *Session) ?*Client { if (s.origin) |i| { const c = &s.clients[i]; @@ -721,8 +440,6 @@ pub const Session = struct { return s.primary(); } - /// Which slot this client is. From the pointer because every caller here - /// holds a `*Client` and not its index. fn slotOf(s: *Session, c: *const Client) u8 { return @intCast(@divExact(@intFromPtr(c) - @intFromPtr(&s.clients[0]), @sizeOf(Client))); } @@ -731,87 +448,36 @@ pub const Session = struct { for (&s.clients) |*c| if (c.attached) s.send(c, msg); } - // ---- the host methods: pseudo-terminals ------------------------------- - fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); if (pane >= s.ptys.len) return; // the core indexes its own panes - // The in-process host's reaping rule and its reason, verbatim from - // tty.zig `spawn`: the core reuses pane ids and there is no close - // effect, so a deleted pane's shell lives in its slot until a respawn - // lands here. s.closePty(pane); - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } + s.harvest(); + if (s.ptys[pane].pid != 0) return s.core.reportError(pane, "shell", error.ShellClosing); + for (s.retired_shells) |shell| { + if (shell.pid == 0) break; + } else return s.core.reportError(pane, "shell", error.ShellClosing); const child = host_io.forkShell( s.core, pane, &s.prompt_rcs, s.core.shellBin(), - cwd_z, - // The SESSION grid — which `reconcile` already made the smallest - // common one across everyone attached, and which survives every - // frontend leaving, so a shell forked into an empty session is - // still sized like the pane the core reflowed. + cwd, s.core.screen_h, s.core.screen_w, - s.fs, - ); - // A `forkpty` that failed left `master` holding a number this process - // does not own. The shells get away with not checking because they hand - // the descriptor to a reader task that simply ends; this one would go - // into `poll(2)`, come back POLLNVAL, and be closed out from under - // whoever really owns it. - if (child.pid < 0) return; + s.ninep, + ) catch |err| return s.core.reportError(pane, "shell", err); s.ptys[pane] = .{ .fd = child.file.handle, .pid = child.pid }; - // ...and the master joins the rule every other descriptor in this file - // obeys. `forkpty` hands it back BLOCKING, and host_io.zig leaves it that - // way because tty.zig streams it from a thread that wants a blocking - // read; a poll loop wants the opposite, and one blocking `write(2)` here - // is the whole session parked. Only this side of the pty is affected — - // the master and the slave are separate open file descriptions, so the - // shell's own stdin stays exactly as `forkpty` made it. setNonblock(child.file.handle); - // The pane's starting directory, for the tags. `pollFrame` keeps it - // current after a `cd`; this is the one before the first frame. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); - } - - /// Keystrokes and pastes into the shell, QUEUED and never blocked on. - /// - /// This was one blocking `host_io.writeFd`, and the comment defending it - /// argued that "the peer is a shell this process forked rather than a - /// stranger who can stop reading on purpose". The peer is whatever program - /// the human ran in the pane: `sleep 3600`, a job stopped with ^Z, anything - /// blocked writing its own output. Any of those plus a paste larger than the - /// pty's input buffer — four kilobytes, and a frontend is entitled to send a - /// four-MEGABYTE paste — put this single-threaded process to sleep inside - /// `write(2)` with the whole session behind it: no frame to any frontend, - /// fifteen other masters unread, no `accept`, no `expire`, no inotify drain. - /// - /// So a pane owes bytes the way a client does, and the answer is the shape - /// this file already had for exactly this problem. What differs is what - /// happens when the queue will not drain: a client that stops reading is - /// CLOSED, and a pane cannot be, because closing it kills a program the - /// human is running. See `pty_backlog` — the write is refused and said out - /// loud on the pane's own message row. + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + } + fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); if (pane >= s.ptys.len) return; const pt = &s.ptys[pane]; - // A pane with no shell swallows what is typed at it, which is exactly - // what the core does with a null method. if (pt.fd < 0) return; - // BEFORE the append, which is `queue`'s rule and gives `queue`'s - // guarantee: one write always lands whole, so the biggest paste anyone - // can send is never truncated on arrival, and what is refused is the - // NEXT one typed at a program that has read nothing. if (pt.out.items.len > pty_backlog) { var mbuf: [256]u8 = undefined; const text = std.fmt.bufPrint( @@ -822,13 +488,8 @@ pub const Session = struct { return s.core.setMessage(pane, text); } pt.out.appendSlice(s.gpa, bytes) catch { - // Out of memory for a keystroke. The shell is fine and the session - // is fine; this one write is not, and saying so is all there is. return s.core.setMessage(pane, "input refused: out of memory"); }; - // Try immediately. On an idle pty this empties the queue in one write and - // the descriptor never asks for a POLLOUT at all, which keeps a session - // of keystrokes exactly as cheap as it was. s.flushPty(pane); } @@ -841,56 +502,32 @@ pub const Session = struct { _ = posix.system.ioctl(fd, TIOCSWINSZ, @intFromPtr(&ws)); } - /// `pty/ctl`'s `sig` — and the host where it matters most, because these - /// shells outlive every frontend: a script that signals a build in a - /// detached session is signalling a process nobody has a terminal on. - /// `fd < 0` is a pane with no shell, the same silence `ptyWrite` gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); if (pane >= s.ptys.len) return; const pt = s.ptys[pane]; if (pt.fd < 0) return; - look.signalTty(pt.pid, pt.fd, sig); + host_io.signalTty(pt.pid, pt.fd, sig); } - /// Is this pane's tty still the prompt we forked, or has a program taken it? - /// - /// Answerable at all only because the pty is HERE. While a pane's shell - /// lived in a frontend this method had to stay null, and a null one means - /// the core types every `Exec` at the shell — into vim, into a pager, into - /// an agent waiting on stdin. Lazy by construction (host.zig): it runs where - /// the core is about to type a command line, so the /proc walk costs an - /// ordinary frame nothing. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); if (pane >= s.ptys.len) return false; const pt = s.ptys[pane]; if (pt.fd < 0) return false; - return look.ttyTaken(pt.pid, pt.fd); + return host_io.ttyTaken(pt.pid, pt.fd); } - // ---- the host methods: the filesystem --------------------------------- - fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // Our own write is about to come back as an inotify edge: restamp from - // the bytes we just put there so the reconcile reads as "no change". - // Only when this IS the pane's watched file — a `Save ` must - // not silence a real change to the file the pane has open. Six lines - // shared with tty.zig `writeFile` over the same `file_watch.Table`, - // which is what makes a save in a detached pane behave like a save in a - // terminal one. if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside - // it: the early return above is a save that did not happen and must not - // be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -899,22 +536,13 @@ pub const Session = struct { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); - // Where it landed, which is what puts `Restore ` in the topbar - // (pardes.zig `write_dump`). A dump of a detached session now lands in - // the same directory a terminal session's does, rather than in whatever - // directory the frontend that happened to be primary was started from. + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } - fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = of(ctx); - // The path argument is unused because `applyEffect` takes it off the - // core's own pane, together with the serial and the generation that make - // the reconcile safe. That is the one thing a frontend could not do — it - // had no core — and it is why the frontend's copy of this method needed a - // second table of pathnames to go with the watch table. - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify(), &s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify(), &s.watches, pane, on, mode)) s.check_files = true; } @@ -926,10 +554,8 @@ pub const Session = struct { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); - // A session started without one has nowhere to put them; the core's - // options are the only place that answer lives. const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -938,182 +564,59 @@ pub const Session = struct { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } - // ---- the host methods: the desktop ------------------------------------ - fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); - // Mirrored into the core's own clipboard ALWAYS, not only when nobody - // is attached: `readClipboard` answers from it when there is no - // frontend, and a frontend can leave between the yank and the paste. A - // yank that a detached session then pasted as the previous yank is the - // bug this one line is. s.core.fallback.setClipboard(text); s.broadcast(.{ .set_clipboard = text }); } - /// The one `pull_` that crosses the wire, and it stays a pull for exactly - /// the reason host.zig gives: two frontends answering would paste the - /// clipboard twice for one Ctrl-V. fn readClipboard(ctx: ?*anyopaque) void { const s = of(ctx); if (s.origins()) |c| return s.send(c, .read_clipboard); - // Nobody attached. This method being non-null means the core will NOT - // reach for its own fallback, so an unanswered request would leave - // `clip_pending` armed forever — host.zig's note that a null method - // answers immediately is the obligation being met here by hand. s.core.update(.{ .paste = s.core.fallback.clipboard.items }); } fn openLink(ctx: ?*anyopaque, url: []const u8) void { const s = of(ctx); if (s.origins()) |c| return s.send(c, .{ .open_link = url }); - // No desktop in reach, so the link goes where a host with no browser - // puts it: the core's record of the last one asked for, which is what - // `Fallback.setLink` is and what the acme filesystem reads back. s.core.fallback.setLink(url); } - // ---- the host methods: session control -------------------------------- - - /// `Detach` in an attached frontend: that frontend leaves, the session and - /// every other frontend carry on. tmux's `detach-client`. - /// - /// A `send` and NOTHING ELSE, and each of the three things it does not do is - /// deliberate. It does not quit — the whole point is that the session - /// survives, and a detach that took the daemon with it would be `quit` under - /// another name. It does not touch the core — no pane closes, no shell dies, - /// no frame changes; the grid is retaken by `reconcile` from the frontends - /// that remain, on the ordinary path, because a frontend leaving is already a - /// case this file handles. And it does not close the connection: the frontend - /// closes its own socket when it reads the message, and the peer-hangup path - /// then frees the slot exactly as it does for a frontend somebody killed. - /// Closing from this side would race the frontend's own teardown for no gain. - /// - /// It is therefore the one vtable entry here that is not an effect on the - /// world but SESSION CONTROL — one frontend asking to stop being a frontend - /// — which is why wire.zig numbers it 0x05, in the session range beside - /// `quit`, rather than in 0x10.. where every tag reaches a disk, a clipboard - /// or a browser. The module header's routing table says the same. - /// - /// ORIGIN, ELSE PRIMARY, for `read_clipboard`'s and `open_link`'s reason: it - /// answers something one particular human just typed, so it has to reach that - /// human's screen and not somebody else's — sending a detach to the wrong - /// frontend takes away a session from a person who did not ask. Nobody - /// attached at all is a no-op, and correctly so: there is no frontend to - /// detach, and the core has nothing to record about one. fn detach(ctx: ?*anyopaque) void { const s = of(ctx); if (s.origins()) |c| s.send(c, .detach); } - /// The core's answer to one filesystem request, handed straight back to the - /// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside - /// `perform` and is borrowed only for this call, so a body read is a window - /// onto the pane's live text and copies nothing. `.again` needs no case: - /// both transports read the status and re-park the request themselves. - /// - /// WHICH transport is `fs_origin`, set by the drain that asked. This used - /// to be `s.fs` unconditionally, which was right while a mount was the only - /// answer there was and became a silent misroute the moment `--fs9` gave - /// the session a second one: `Fs.reply` looks the tag up in ITS park table, - /// finds nothing, and returns — so a 9P `Tattach` was answered into the - /// void and its client waited forever. Measured against plan9port's `9p`. - fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = of(ctx); - if (s.fs_origin) |t| return t.reply(reply, bytes); - if (s.fs) |f| f.reply(reply, bytes); - } - - // ---- pane shells ------------------------------------------------------ - - /// Each pane's live cwd, for the tags. One readlink of /proc per pane that - /// has a shell, per frame, which is what tty.zig's `pollFrame` costs — and - /// why the far more expensive question, whether a program has taken the - /// pane's tty, is a pull asked at the `Exec` that cares (`ttyTaken`) - /// instead of polled here. - /// - /// This could not exist before. A detached session's shells lived in a - /// frontend, and a frontend has no core to report a cwd TO, so a `cd` in a - /// detached pane never reached its tag no matter how many frontends were - /// watching. The pids are here now, so it does. fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); - // acme's filesystem first in the pass, for the reason tty.zig gives at - // its own call site: an edit a script just made through `body` belongs - // in the surface this frame composes, not the next one. The flag is - // read by `waitInput`, which must not sleep while the kernel still has - // requests we have not acknowledged. - if (s.fs) |f| { - s.fs_origin = f.transport(); - s.fs_pending = fs_service.drain(s.fs_origin.?, s.core).pending; - s.fs_origin = null; - } - // ...and the 9P connections, in the same breath and for the same - // reason. One connection is one `Transport`, so this is - // `fs_service.drain` per connection per frame, with `fs_origin` naming - // the one being served so its replies come back to it (see `fsReply`). - // HERE rather than in `dispatch` for `Source.ninep`'s reason: a - // `Twrite` to `ctl` can `push_spawn`, and `dispatch` is mid-iteration - // over a descriptor snapshot when it runs. - if (s.ninep) |l| { - // Before the drain, so a slot held by silence is taken back on the - // same frame it expires rather than one drain later. - l.expire(); - var pending = false; - for (0..fs9_service.max_conns) |i| { - const t = l.transport(@intCast(i)) orelse continue; - s.fs_origin = t; - const d = fs_service.drain(t, s.core); - s.fs_origin = null; - if (l.settle(@intCast(i), d)) pending = true; - } - s.ninep_pending = pending; - } + if (s.ninep) |l| s.ninep_pending = if (ninep_io.quic_enabled and l.quic != null) l.tick(s.core).pending else l.drain(s.core).pending; for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(pane, cwd); } } - /// Drop a pane's shell: out of the poll set, out of the process. Closing the - /// master is what hangs the shell up — which is true only because - /// `host_io.forkShell` puts FD_CLOEXEC on it, so no LATER pane's shell is - /// still holding a copy open. The pid is left to `harvest`, because a - /// `waitpid` here would return 0 for a shell that has not noticed the hangup - /// yet and that answer is worth nothing. - /// - /// The core is NOT told. Its two callers are `spawn` — a respawn, where the - /// core is the thing that asked — and `deinit`, where there is no core left - /// to tell. The path that does tell it is `paneEof`. fn closePty(s: *Session, pane: u8) void { const pt = &s.ptys[pane]; if (pt.fd < 0) return; _ = libc.close(pt.fd); - // Before the reset, or the queue's allocation goes with the slot: what - // is in it is input a program that is not reading never took, and there - // is nobody left to hand it to. + pt.fd = -1; pt.out.deinit(s.gpa); - pt.* = .{}; - } - - /// Push what the kernel will take of what this pane owes its shell, and - /// leave the rest for a POLLOUT. `flush`'s body, on a pty instead of a - /// socket, down to the `retire` that hands a drained megabyte back. - /// - /// The one difference is what an error means. A failed write to a SOCKET - /// closes a client; a failed write to a master means the slave side is gone, - /// which is the same event as a read of 0. It is NOT the same moment, - /// though, and that is why the error arm reads the pane before it ends it: - /// linux's `n_tty_write` returns EIO the instant the slave has no open - /// descriptors left, while `n_tty_read` on that same master still hands back - /// what the shell wrote before it went — so the write fails while the last - /// line is still retrievable, and ending the pane first would throw it away. - /// That is the very thing the dispatch's `.pty` branch protects against when - /// it takes POLLIN before POLLHUP, and it has to hold here too, because two - /// paths reach this arm with no read of their own in between: the dispatch - /// runs POLLOUT before POLLIN, and `ptyWrite` calls this during `perform`, - /// after this round's `readPty` has already been and gone. + pt.out = .empty; + if (pt.pid == 0) return; + _ = libc.kill(pt.pid, libc.SIG.HUP); + pt.kill_at = monotonicMs() + 100; + s.harvest(); + if (pt.pid == 0) return; + for (&s.retired_shells) |*shell| if (shell.pid == 0) { + shell.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; + pt.pid = 0; + pt.kill_at = 0; + return; + }; + } + fn flushPty(s: *Session, pane: u8) void { const pt = &s.ptys[pane]; var off: usize = 0; @@ -1121,22 +624,13 @@ pub const Session = struct { const n = libc.write(pt.fd, pt.out.items.ptr + off, pt.out.items.len - off); if (n < 0) switch (libc.errno(n)) { .INTR => continue, - // The pty's input buffer is full: the rest waits for POLLOUT, - // and this is the case the whole change exists for. .AGAIN => break, else => { - // `readPty` either takes that last chunk or reaches the end - // itself and has already ended the pane; the guard is what - // stops the second `paneEof` from being a double-end. s.readPty(pane); if (s.ptys[pane].fd >= 0) s.paneEof(pane); return; }, }; - // No progress and no error. host_io.zig's `writeFd` says why this is - // a `break` and never a retry: looping on a zero-byte write is a - // spin, and a spin in here is the whole session at 100% of a core - // with no syscall for a signal to interrupt. if (n == 0) break; off += @intCast(n); } @@ -1149,119 +643,51 @@ pub const Session = struct { pt.out.items.len -= off; } - /// One read per readable pty per round — `receive`'s rule for clients, - /// applied to shells: a `yes` in pane 1 gets one turn and the loop moves on - /// to the other panes, the frontends and the frame. - /// - /// Nothing is copied. `Event.output` borrows the buffer for the length of - /// one `update` call, which is the same borrow window every other host gives - /// a pty chunk — tty.zig frees its duplicate the line after the update — - /// except that this one never allocated a duplicate to free. A daemon - /// serving sixteen shells printing build logs asks the allocator for - /// nothing. fn readPty(s: *Session, pane: u8) void { var buf: [pty_chunk]u8 = undefined; const got = libc.read(s.ptys[pane].fd, &buf, buf.len); if (got == 0) return s.paneEof(pane); if (got < 0) return switch (libc.errno(got)) { - // A master that said POLLIN and then had nothing is not an error; - // the next round asks again. .INTR, .AGAIN => {}, - // EIO is how linux reports the slave side going away, which is the - // ordinary end of a shell rather than a fault. else => s.paneEof(pane), }; s.core.update(.{ .output = .{ .pane = pane, .bytes = buf[0..@intCast(got)] } }); } - /// The shell in `pane` is gone. The descriptor leaves the poll set BEFORE - /// the core is told, because an `eof` is what makes the core offer a respawn - /// and a respawn into a slot still holding the old fd would leak it. fn paneEof(s: *Session, pane: u8) void { s.closePty(pane); s.harvest(); s.core.update(.{ .eof = .{ .pane = pane } }); } - /// Collect every child that has exited. - /// - /// `waitpid(-1)` and not a pid list, because the only children this process - /// LEAVES UNREAPED are pane shells (`host_io.forkShell`) — so "any exited - /// child" and "an exited pane shell" are the same set — and because the pids - /// a list would hold are exactly the ones it cannot help with: a respawn - /// closes a master, and the shell that gets the hangup exits some - /// milliseconds later with its slot already reused by a different shell. - /// Anything else this process forks — `fusermount3`, from `Fs.mount`, - /// `sweepStale` and `Fs.deinit` — is reaped by its own spawner with a - /// pid-specific blocking wait before control returns here, so the set this - /// sees is still only shells. A future worker that forks and does not wait - /// would break that, and this is the sentence it has to come back and edit. - /// - /// Nothing here waits, so a session whose shells are all running pays one - /// syscall that returns 0. Called once per poll round and again wherever a - /// shell is dropped, which is what keeps a daemon that runs for a week and - /// spawns a thousand shells free of zombies — the one bookkeeping cost a - /// long-lived process pays that a frontend, which exits, never did. - fn harvest(_: *Session) void { - while (true) { - // 0: there are children and none has exited. -1: no children at all. - if (libc.waitpid(-1, null, libc.W.NOHANG) <= 0) return; + fn harvest(s: *Session) void { + const now = monotonicMs(); + for (&s.retired_shells) |*shell| reapShell(&shell.pid, &shell.kill_at, now); + for (&s.ptys) |*pty| if (pty.fd < 0) reapShell(&pty.pid, &pty.kill_at, now); + } + + fn reapShell(pid: *posix.pid_t, kill_at: *i64, now: i64) void { + if (pid.* == 0) return; + const result = libc.waitpid(pid.*, null, libc.W.NOHANG); + if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = libc.kill(pid.*, libc.SIG.KILL); + kill_at.* = 0; } } - // ---- watched files ---------------------------------------------------- - - /// The one inotify descriptor behind every watch, opened on first use. - /// - /// Lazy for two reasons pointing the same way: a `Session` is built as a - /// struct literal (client.zig's test harness is one) and so has no init hook - /// to open it in, and a session whose panes are all shells never watches a - /// path and has no use for one. -1 on anything but linux and on a failed - /// `inotify_init1`, which file_watch.zig reads as "mark nothing" — the core - /// then keeps its own record of what was asked and simply never gets a - /// reload, which is what a host with no watcher has always done. - /// - /// `polled` is true because this descriptor is drained from `poll`, not - /// from a thread parked in a wait (tty.zig `watchFiles`): `drainInotify` - /// must be able to stop. On linux that is IN_NONBLOCK; on macos a kqueue - /// needs nothing, since the timeout argument to `kevent(2)` decides. fn inotify(s: *Session) c_int { if (s.inotify_fd >= 0) return s.inotify_fd; s.inotify_fd = file_watch.init(true); return s.inotify_fd; } - /// A directory this session marked had an edge. The CONTENTS are discarded - /// on purpose, exactly as tty.zig's watcher thread discards them: a record - /// names a mark and a filename, and reconciling every mark against the - /// generation the core accepted is both cheaper and safer than deciding from - /// the record which pane it meant. - /// - /// DRAINED TO EMPTY, in a loop, and one read was a real cost rather than the - /// coalescing this comment used to claim. The descriptor is level-triggered, - /// so a queue left partly full makes `poll` return ready again immediately — - /// and each of those rounds is a whole `pump`: `reloadChanged` over all 17 - /// slots, every watched text pane re-read from disk and re-hashed, a render, - /// a present. A `git checkout` can queue the kernel's whole 16384 events; at - /// roughly 128 records per 4 KiB that was ~128 spin rounds and some two - /// thousand whole-file reads for one command, at 100% of a core, while every - /// frontend got a frame per round it could not use. The fd is IN_NONBLOCK - /// (`inotify`), so the loop ends on EAGAIN. fn drainInotify(s: *Session) void { if (file_watch.drain(s.inotify_fd)) s.check_files = true; } - /// Reconcile every marked pane and the theme file. Called at the END of - /// `waitInput`, which is what puts a reload in THIS frame: `Pardes.pump` - /// renders after `pull_wait_input` returns. - /// - /// The loop is `reloadChanged`'s contract. It asks for another pass when a - /// PDF's pathname changed between the stat before MuPDF reopened it and the - /// stat after — a save that landed mid-reconcile, where committing either - /// identity would lose a generation. tty.zig posts that request back into - /// its event queue; this loop has no queue, so it is retried here and - /// BOUNDED, because a file being rewritten in a loop must not hold the core. - /// What is left over is picked up by the next directory edge. fn reloadWatched(s: *Session) void { if (!s.check_files) return; s.check_files = false; @@ -1270,16 +696,10 @@ pub const Session = struct { } } - // ---- the frame -------------------------------------------------------- - fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = of(ctx); for (&s.clients) |*c| { if (!c.attached) continue; - // A client that has not drained what it already owes does not get - // this frame, and its mirror is deliberately left where it is: the - // next frame it does get is a diff against what it really has. A - // slow frontend gets fewer, larger frames rather than a queue. if (c.out.items.len != 0) continue; s.sendFrame(c, surface); } @@ -1289,8 +709,6 @@ pub const Session = struct { const cells = surface.cells; const want = wire.frameBound(surface.cols, surface.rows); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); - // Nothing comparable on the far side is the LATE JOINER and the RESIZE - // in one test: either way the whole grid has to be described. const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len) &.{} else @@ -1307,61 +725,31 @@ pub const Session = struct { cells, prev, ) catch |err| { - // A frame this protocol cannot carry is a grid past `max_cols` / - // `max_rows`, or a cursor the core placed outside its own surface. - // Dropping the frame keeps the session alive with a stale screen, - // which is strictly better than dropping the frontend — a frontend - // REFUSES such a frame and hangs up — and the log says which. log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; }; s.queue(c, bytes); if (c.fd < 0) return; // the queue closed it; the mirror went with it - // The mirror advances only now, and only because the bytes are on the - // wire or in the kernel's buffer for it. c.mirror.resize(s.gpa, cells.len) catch return s.close(c, .oom); @memcpy(c.mirror.items, cells); c.need_full = false; } - // ---- the loop --------------------------------------------------------- - - /// The only place this process sleeps, which is what `pull_wait_input`'s - /// comment in host.zig requires of whoever serves it, and the only place it - /// waits on ANYTHING: one `poll(2)` over the listener, every attached - /// frontend, every pane's pty master and the inotify descriptor. No thread - /// per client, no thread per shell, no watcher thread, and nothing here - /// blocks on a single peer. - /// - /// That the shells are in this set and not on threads of their own is what - /// lets a daemon own sixteen of them and stay a single-threaded state - /// machine — and it costs the shells nothing, because a pty master is - /// pollable and a pane's output has nowhere to go but the core this loop is - /// driving anyway. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); - // Push what the kernel will take before sleeping: a client that becomes - // writable while we are inside poll(2) would otherwise be a frame late, - // and a frame late is a frame skipped (see `present`). + const completed = s.drainCompletions(true); for (&s.clients) |*c| if (c.fd >= 0) s.flush(c); - // The session grid, retaken BEFORE the sleep as well as after it. A - // client can leave OUTSIDE this function — a `set_clipboard` broadcast - // whose write failed during `perform` closes it — and the minimum across - // attached frontends would then stay sized for a frontend that is gone - // until some descriptor happened to become readable, which on an idle - // session is never. That is what this call buys, and `regridded` is what - // it costs: a round that has just told the core to reflow must not then - // sleep on it, because the frame carrying that reflow is the one - // `Pardes.pump` composes the moment this returns. const regridded = s.reconcile(); const now = monotonicMs(); var fds: [poll_slots]libc.pollfd = undefined; var src: [poll_slots]Source = undefined; var n: usize = 0; - // The listener is left OUT of the set while accepting is paused, which - // is how an EMFILE is waited out without the core sleeping (see - // `accept`). Every frontend already attached goes on being served. + if (s.mailbox.wake[0] >= 0) { + fds[n] = .{ .fd = s.mailbox.wake[0], .events = poll_in, .revents = 0 }; + src[n] = .completion; + n += 1; + } const watching_listener = s.listener >= 0 and now >= s.accept_paused_ms; if (watching_listener) { fds[n] = .{ .fd = s.listener, .events = poll_in, .revents = 0 }; @@ -1378,68 +766,39 @@ pub const Session = struct { src[n] = .{ .client = @intCast(i) }; n += 1; } - // The pane shells, and note what is NOT conditional on a frontend: a - // session with nobody attached still polls these, still reads them and - // still feeds the core. That is the difference between a detach that - // pauses your build and a detach that does not. for (&s.ptys, 0..) |*pt, pane| { if (pt.fd < 0) continue; fds[n] = .{ .fd = pt.fd, - // POLLOUT only while this pane owes its shell bytes, which is - // the same rule and the same reason as a client's: asking for it - // unconditionally makes every idle pty a ready descriptor and - // turns the poll into a spin. .events = if (pt.out.items.len != 0) poll_in | poll_out else poll_in, .revents = 0, }; src[n] = .{ .pty = @intCast(pane) }; n += 1; } - // Opened only once something asked to be watched, so an unwatched - // session simply has one fewer descriptor here (see `inotify`). if (s.inotify_fd >= 0) { fds[n] = .{ .fd = s.inotify_fd, .events = poll_in, .revents = 0 }; src[n] = .inotify; n += 1; } - // ...and acme's filesystem, when there is one. Its arm in `dispatch` - // does nothing: this descriptor is here to END THE SLEEP, so that the - // `pollFrame` after `pull_wait_input` returns reaches the drain. The - // desktop shells buy the same wake with a thread; one poll slot is - // cheaper and cannot race the loop. - // ...and NOT once it is dead. `fuse_dev_poll` answers `EPOLLERR` as soon - // as the connection is gone, POSIX reports `POLLERR` whatever the events - // mask asked for, and this arm cannot consume it — so an external - // `fusermount3 -u`, a sysfs abort, or systemd taking `/run/user/$UID` - // away at final logout (exactly when a detached session is supposed to - // keep running) would make `poll(2)` return instantly, forever, and burn - // a whole core for the life of the daemon. Measured at 100% of one CPU - // before this guard. fuse.zig's own poll thread has carried the - // equivalent check all along, which is why the desktop shells never - // showed it and this loop did. - if (s.fs) |f| if (f.fd >= 0 and !f.dead) { - fds[n] = .{ .fd = f.fd, .events = poll_in, .revents = 0 }; - src[n] = .fuse; - n += 1; - }; - // ...and the 9P socket, when there is one: the listener, plus one - // descriptor per live connection. POLLOUT only while a connection owes - // bytes, which is the same rule and the same reason as a client's and - // a pty's — asking for it unconditionally makes every idle socket a - // ready descriptor and turns the poll into a spin. if (s.ninep) |l| { - // `accepting`, not `fd >= 0`: a listener paused after an EMFILE - // must leave the set, or the backlog it could not drain reports - // ready on every poll and spins the core. `nextDue` carries the - // moment it comes back. if (l.accepting()) { - fds[n] = .{ .fd = l.fd, .events = poll_in, .revents = 0 }; - src[n] = .ninep_listener; - n += 1; + for ([_]c_int{ l.fd, l.tcp_fd }) |fd| { + if (fd < 0) continue; + fds[n] = .{ .fd = fd, .events = poll_in, .revents = 0 }; + src[n] = .ninep_listener; + n += 1; + } } - for (0..fs9_service.max_conns) |i| { - if (!l.live(@intCast(i))) continue; + if (comptime ninep_io.quic_enabled) { + if (l.quic) |*listener| { + fds[n] = listener.poll(); + src[n] = .ninep_quic; + n += 1; + } + } + for (0..ninep_io.max_conns) |i| { + if (l.conns[i].fd < 0) continue; fds[n] = .{ .fd = l.conns[i].fd, .events = if (l.owes(@intCast(i))) poll_in | poll_out else poll_in, @@ -1449,108 +808,38 @@ pub const Session = struct { n += 1; } } - // A session with no listener, no clients, no shells and no watches has - // no event source at all. Returning immediately would spin the outer - // `while (!core.quit)` at full speed, so sleep the interval the core - // offered and, when it offered none, a frame's worth. - // - // Nothing is owed on this path. `check_files` is only ever set by a - // watch, and a watch means the inotify descriptor is in the set; - // `reconcile` posts a resize only when a client is ATTACHED, which means - // its socket is in the set; and `fs_pending` is only ever set by a drain, - // which runs only when `fs` is live, which puts `/dev/fuse` in the set. - // So `n == 0` implies `!regridded` and `!fs_pending` too. `--fs9` is - // the ONE exception, and it is why `ninep_pending` is named on the - // `timeout = 0` line below rather than here: a hung-up 9P connection - // still owing the core its orphaned fids has no descriptor at all, so - // it can be the only work left with `n == 0`. It is also finite — one - // release per fid — so the 16 ms nap that path takes costs it a couple - // of frames and never a stall. if (n == 0) return nap(if (timeout_ms == 0) 16 else timeout_ms); - // Zero is the core's word for "sleep until something happens" (see - // pardes.zig `pump`: it passes a frame interval only while an animation - // is running). poll spells that -1. var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); - // Two things here are due on a CLOCK rather than on a descriptor: a - // handshake that has to expire, and a paused listener that has to come - // back. An indefinite poll would sit through both — and thirty-two - // peers that connect and then say nothing, with the session otherwise - // idle, IS the denial `greet_deadline_ms` exists to answer — so the - // wait is clamped to whichever is due first. if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due); - // ...and two things are due on nothing at all rather than on a - // descriptor: a reconcile pass a watch effect asked for (`watchFile` ran - // during `perform`, outside this function) and a regrid this round has - // already performed. Both are consumed before this function returns, so - // the round must not sleep before reaching them. - if (s.check_files or regridded or s.fs_pending or s.ninep_pending) timeout = 0; + if (completed or s.check_files or regridded or s.ninep_pending) timeout = 0; const ready = libc.poll(&fds, @intCast(n), timeout); - // A timeout is an ordinary frame boundary and EINTR is a signal we do not - // handle here. Neither skips anything below any more: what used to be an - // early `return` here is why a client closed without any descriptor being - // readable — which is every `expire` — left the session grid sized for a - // frontend that had gone, until the next readable event, on an idle - // session possibly hours later. if (ready > 0) s.dispatch(fds[0..n], src[0..n]); - // AFTER the dispatch, and that ordering is itself a fix. `expire` frees a - // client slot and `accept` — which runs INSIDE the dispatch — fills the - // lowest free one, so an expire that ran first could hand a slot to a new - // connection within this same round and the dispatch would then apply the - // OLD connection's `revents` to the new descriptor: a POLLHUP from the - // peer that left, closing the peer that just arrived. The dispatch's - // `c.fd < 0` guard cannot see that, because the fd is perfectly valid — - // it is simply a different fd. Expiring after means a freed slot is - // refilled no earlier than the next round, which builds a fresh `fds` for - // it. It fixes a smaller thing for free, too: a connection whose `hello` - // arrived in THIS round is attached before its deadline is judged, - // instead of being taken back with its handshake still unread. + _ = s.drainCompletions(true); s.expire(monotonicMs()); - // Unconditional, and not only where a shell is noticed to have died: a - // shell whose master `spawn` closed on a respawn exits after that close, - // with no descriptor left for anyone to see it on. See `harvest`. s.harvest(); - // Both before this function returns, so a file that changed on disk and a - // frontend that left during this round are in the frame `Pardes.pump` - // composes next rather than the one after it. s.reloadWatched(); _ = s.reconcile(); } - /// One pass over the descriptors `poll` reported ready. Split out of - /// `waitInput` for one reason: everything that must happen AFTER it — - /// `expire`, `harvest`, `reloadWatched`, `reconcile` — is then stated once, - /// in one order, where no early return can skip it. An early return past - /// that list is exactly what findings 5 and 6 were. fn dispatch(s: *Session, fds: []const libc.pollfd, src: []const Source) void { for (fds, src) |pfd, source| switch (source) { .listener => if (pfd.revents != 0) s.accept(), + .completion => {}, .client => |i| { const c = &s.clients[i]; - // A slot closed earlier in this same pass (its peer hung up, a - // decode failed) must not be touched through a stale revents. if (c.fd < 0) continue; if (pfd.revents & poll_out != 0) s.flush(c); if (c.fd < 0) continue; if (pfd.revents & poll_in != 0) { s.receive(c, i); } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { - // POLLIN wins when both are set: a peer that wrote and then - // closed has bytes still worth reading. s.close(c, .peer); } }, .pty => |pane| { if (s.ptys[pane].fd < 0) continue; - // What this pane still owes its shell, which is the whole of - // finding 1's drain: `ptyWrite` queued it and stopped at EAGAIN - // rather than sleeping, and this is where the rest goes. if (pfd.revents & poll_out != 0) s.flushPty(pane); - // `flushPty` ends the pane when the slave side has gone. if (s.ptys[pane].fd < 0) continue; - // The same precedence as a client's, and it matters more here: a - // shell that printed its last line and exited reports - // POLLIN|POLLHUP together, and taking the hangup first would - // throw that line away. `readPty` reaches the EOF by reading 0. if (pfd.revents & poll_in != 0) { s.readPty(pane); } else if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { @@ -1558,23 +847,10 @@ pub const Session = struct { } }, .inotify => if (pfd.revents & poll_in != 0) s.drainInotify(), - // The only revents worth a word: a dead connection must leave the - // set, or the `POLLERR` it reports on every future poll spins the - // loop. `Fs.next` would set `dead` on its first failed read anyway; - // saying it here costs nothing and saves the one spinning round. - .fuse => if (pfd.revents & (poll_hup | poll_err | poll_nval) != 0) { - if (s.fs) |f| f.dead = true; - }, .ninep_listener => if (pfd.revents != 0) { if (s.ninep) |l| l.accept(); }, - // BYTES ONLY. The requests those bytes decode into are served by - // `pollFrame`, after this snapshot is done with — see - // `Source.ninep`. POLLOUT before POLLIN so a reply the last frame - // could not finish writing goes before more work arrives, and - // POLLIN before the hangup because a script that wrote a `Tclunk` - // and closed has bytes still worth reading; the `live` guard is the - // client slots' `c.fd < 0`, for its reason. + .ninep_quic => {}, .ninep => |i| if (s.ninep) |l| { if (!l.live(i)) continue; if (pfd.revents & poll_out != 0) l.flush(i); @@ -1588,13 +864,17 @@ pub const Session = struct { }; } - /// Milliseconds until the next deadline that is kept by the CLOCK rather - /// than by a descriptor, or null when there is none. Floored at zero, so a - /// deadline already past polls once without blocking instead of blocking - /// forever on a negative timeout. fn nextWake(s: *const Session, now: i64) ?c_int { if (now == 0) return null; // no clock; see `monotonicMs` var due: ?i64 = null; + for (s.retired_shells) |shell| if (shell.pid != 0) { + due = now + 10; + break; + }; + for (s.ptys) |pty| if (pty.fd < 0 and pty.pid != 0) { + due = now + 10; + break; + }; for (&s.clients) |*c| { if (c.fd < 0 or c.attached) continue; const at = c.accepted_ms + @as(i64, s.greet_deadline_ms); @@ -1602,8 +882,6 @@ pub const Session = struct { } if (s.listener >= 0 and s.accept_paused_ms > now) due = if (due) |d| @min(d, s.accept_paused_ms) else s.accept_paused_ms; - // ...and the 9P listener's own greet deadline, for the reason its - // `expire` gives: four slots is a cheaper denial than thirty-two. if (s.ninep) |l| { if (l.nextDue()) |ms| { const at9 = now + ms; @@ -1614,10 +892,6 @@ pub const Session = struct { return @intCast(@max(0, @min(at - now, std.math.maxInt(c_int)))); } - /// Take the slots of connections that never said `hello` back. A connection - /// that holds a slot in silence denies a real frontend exactly as a queue - /// would, and `Client.open` writes its hello in the same call that - /// connects, so there is nothing legitimate to wait for. fn expire(s: *Session, now: i64) void { if (now == 0) return; // no clock: enforce nothing rather than everything for (&s.clients) |*c| { @@ -1626,55 +900,27 @@ pub const Session = struct { } } - /// Always accept, even with a full table: the tempting alternative — stop - /// accepting and let the kernel hold the surplus — is a spin, because - /// `poll` is level triggered and an unaccepted backlog reports ready - /// forever. fuse.zig's park table learned that as a deadlock; here it is - /// 100% of a core. - /// - /// BOUNDED all the same. `max_clients + 1` is enough to fill an empty table - /// and refuse one more, and past that the surplus waits in the backlog for - /// the next round — one pump later, with every frontend drawn in between. - /// The `while (true)` this replaces let a peer dialling in a loop hold the - /// core inside `accept` for as long as it kept dialling, and the core is - /// what draws every other frontend's screen. fn accept(s: *Session) void { for (0..max_clients + 1) |_| { const fd = libc.accept(s.listener, null, null); if (fd < 0) { switch (libc.errno(fd)) { - // The backlog is empty, which is this loop's ordinary exit. .AGAIN, .INTR, .CONNABORTED => return, - // Anything else — EMFILE above all — persists until some - // other descriptor is freed, and `poll` is LEVEL - // triggered: coming straight back means poll reports the - // listener ready again immediately and the core spins at - // 100% until the condition clears. The old answer was a - // 100 ms nanosleep, which parks the CORE — every attached - // frontend stops being drawn for a tenth of a second - // because a descriptor ran out. So the LISTENER is dropped - // from the poll set for that beat instead, and the session - // goes on serving the frontends it has. else => { s.accept_paused_ms = monotonicMs() + accept_pause_ms; return; }, } } - nested.setCloexec(fd); + ninep_io.setCloexec(fd); setNonblock(fd); if (comptime darwin) { - // linux says MSG_NOSIGNAL per write; darwin says it once per - // socket. Either way a frontend that dies mid-frame must not - // take the session down with SIGPIPE. const on: c_int = 1; _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); } const slot = for (&s.clients, 0..) |*c, i| { if (c.fd < 0) break i; } else { - // Refused, and told why, on a connection accepted purely so - // that the listener stays quiet. s.refuseFd(fd, .full); _ = libc.close(fd); continue; @@ -1683,10 +929,6 @@ pub const Session = struct { } } - /// One read per client per round. A frontend that never stops talking gets - /// one turn and then the loop moves on to the others and to the frame — - /// which is fuse.zig's `retry` rule (one attempt per parked request per - /// frame) applied to sockets. fn receive(s: *Session, c: *Client, slot: u8) void { var buf: [read_chunk]u8 = undefined; const got = libc.read(c.fd, &buf, buf.len); @@ -1696,9 +938,6 @@ pub const Session = struct { else => s.close(c, .read), }; c.in.appendSlice(s.gpa, buf[0..@intCast(got)]) catch return s.close(c, .oom); - // The table's own ceiling, checked where the table grows: a peer that - // sends the first half of a 16 MiB message and stops is holding memory - // no per-message check can see. See `session_backlog`. s.account(); if (c.fd < 0) return; // it was this one s.consume(c, slot); @@ -1709,9 +948,6 @@ pub const Session = struct { while (true) { const found = wire.framed(c.in.items[off..]) catch return s.close(c, .protocol); const msg = found orelse break; - // The decoded Event BORROWS these bytes, so the buffer is not - // compacted until every message already in it has been applied — - // the same borrow window the tty host gives a pty chunk. s.apply(c, slot, msg.tag, msg.payload) catch return s.close(c, .protocol); if (c.fd < 0) return; // apply closed it, buffers and all off += msg.total; @@ -1726,21 +962,7 @@ pub const Session = struct { } fn apply(s: *Session, c: *Client, slot: u8, tag: u8, payload: []const u8) wire.Error!void { - // `Hello.version` BEFORE the payload is decoded, which is the whole - // point of wire.zig putting it first at a fixed offset: a mismatch has to - // stay diagnosable when the rest of the layout is the part that changed. - // Checking it inside the `.hello` arm defeated exactly that guarantee — - // `decodeClient` refuses a cols/rows this build does not like and refuses - // trailing bytes, so a v2 hello with one extra field came back as - // `.protocol` and the `refuse .version` the frontend needs to say - // something useful was never sent. `wire.helloVersion` reads the one - // field without decoding the rest, and lives in the file that owns the - // layout. if (tag == @intFromEnum(wire.ClientTag.hello)) { - // A second hello on one connection is not a resize; it is a peer - // that is not speaking this protocol. Judged here rather than in the - // arm below so that a repeat hello is a protocol error whatever - // version it claims. if (c.attached) return error.BadValue; const claimed = try wire.helloVersion(payload); if (claimed != wire.version) { @@ -1755,20 +977,12 @@ pub const Session = struct { c.rows = h.rows; c.attached = true; c.need_full = true; - // Greeted after `reconcile`, so the geometry in the welcome is - // the one this client's first frame will actually use. c.greet = true; }, .bye => s.close(c, .bye), .event => |ev| { - // Input before a handshake has no geometry behind it and no - // version agreement either. if (!c.attached) return error.BadValue; switch (ev) { - // A frontend's resize is about ITS window. The core only - // ever sees the smallest common grid, which `reconcile` - // posts once per round when it moves — forwarding this raw - // would let whichever frontend resized last win. .resize => |r| { c.cols = r.cols; c.rows = r.rows; @@ -1782,14 +996,6 @@ pub const Session = struct { } } - /// Settle the session grid and greet whoever arrived, once per poll round - /// rather than once per message: three frontends attaching in the same - /// round are one resize, not three reflows of every pane. - /// - /// True when the CORE was told to reflow, which is the one thing a caller - /// has to react to: the frame carrying that reflow is the next one - /// `Pardes.pump` composes, so a `waitInput` that hears true must not go to - /// sleep before returning. See its `regridded`. fn reconcile(s: *Session) bool { var cols: u16 = 0; var rows: u16 = 0; @@ -1798,17 +1004,10 @@ pub const Session = struct { cols = if (cols == 0) c.cols else @min(cols, c.cols); rows = if (rows == 0) c.rows else @min(rows, c.rows); } - // Nobody attached: keep the grid we had. A detached session is not a - // session of no size, it is one nobody is looking at, and reflowing - // every pane to nothing for zero readers is work with no reader. var regridded = false; if (cols != 0 and (cols != s.cols or rows != s.rows)) { s.cols = cols; s.rows = rows; - // Every mirror is now the wrong shape. `encodeFrame` reaches the - // same conclusion from the cell count alone, but saying it here is - // what makes a reshape with the SAME cell count (80x24 -> 48x40) - // safe too. for (&s.clients) |*c| c.need_full = true; s.core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); regridded = true; @@ -1821,18 +1020,10 @@ pub const Session = struct { return regridded; } - // ---- bytes ------------------------------------------------------------ - fn send(s: *Session, c: *Client, msg: wire.ServerMsg) void { const want = wire.serverBound(msg); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const bytes = wire.encodeServer(s.scratch.allocatedSlice()[0..want], msg) catch |err| { - // The only reachable case is a payload past `max_payload`, and with - // every effect that carried a whole file gone from this protocol the - // only payload that can still get there is a yank of more than - // 16 MiB. The session keeps it — `setClipboard` put it in the core's - // own clipboard before this was ever queued — and the frontends' - // desktop clipboards do not get it, out loud rather than silently. log.debug("message {t} not encodable: {t}", .{ msg, err }); return; }; @@ -1840,36 +1031,13 @@ pub const Session = struct { } fn queue(s: *Session, c: *Client, bytes: []const u8) void { - // BEFORE the append, so one oversized message always goes out whole and - // what this refuses is a client that has stopped draining. if (c.out.items.len > out_backlog) return s.close(c, .backlog); - // ...and the table as a whole, which `out_backlog` does not bound: 32 - // slots one byte under it each, plus a frame apiece. See - // `session_backlog`. s.account(); if (c.fd < 0) return; // the fattest peer was this one c.out.appendSlice(s.gpa, bytes) catch return s.close(c, .oom); - // Try immediately: on a local socket this empties the queue in one - // write, and `present` skips a client whose queue is not empty. s.flush(c); } - /// Close the peer holding the most of the table when the table as a whole - /// is over `session_backlog`. One peer per call, and the fattest one, - /// because this is only ever asked when the total is already over and the - /// peer holding the most of it is the peer that stopped reading. The next - /// append asks again, so a second offender is closed a message later rather - /// than in a loop that could empty the table on one bad frame. - /// - /// `items.len` and NOT `capacity`, which was half of the bug in - /// `session_backlog`'s history. An ArrayList grows geometrically, so a - /// client's `in.capacity` crossed a 4 MiB ceiling while it was still - /// assembling a paste of roughly 2.8 MiB — the peer was punished for the - /// allocator's rounding rather than for anything it held. What this is - /// asking is "how much is a peer making this session hold RIGHT NOW", and - /// that is `items.len`; capacity above it is transient by construction, - /// because `retire` hands back anything over `idle_retain` the moment a - /// buffer empties. fn account(s: *Session) void { var total: usize = 0; var worst: ?*Client = null; @@ -1893,8 +1061,6 @@ pub const Session = struct { const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal); if (n < 0) switch (libc.errno(n)) { .INTR => continue, - // The kernel's buffer is full: the rest waits for POLLOUT, and - // this client is skipped for frames until it drains. .AGAIN => break, else => return s.close(c, .write), }; @@ -1910,18 +1076,11 @@ pub const Session = struct { c.out.items.len -= off; } - /// Say why, then hang up. The refusal is written with a plain blocking - /// write on a socket nobody has sent anything on yet: it is six bytes, and - /// queueing it would mean keeping a slot for a connection being rejected. fn refuse(s: *Session, c: *Client, why: wire.Refusal) void { s.refuseFd(c.fd, why); s.close(c, .refused); } - /// Writes only. The descriptor belongs to the caller — `refuse` hands it to - /// `close`, and the full-table path in `accept` closes it itself — because - /// closing here as well is a double close, and the number is reusable the - /// instant the first one lands. fn refuseFd(_: *Session, fd: c_int, why: wire.Refusal) void { var buf: [wire.header_len + 1]u8 = undefined; const bytes = wire.encodeServer(&buf, .{ .refuse = why }) catch unreachable; @@ -1934,10 +1093,6 @@ pub const Session = struct { } } - /// Free one slot. A frontend dying takes NOTHING with it: not the core, not - /// the listener, not another frontend's frames, and — since this file - /// forks — not its panes' shells either. Its buffers go back and the slot is - /// reusable on the next connect. fn close(s: *Session, c: *Client, why: Closed) void { if (c.fd < 0) return; log.debug("frontend detached: {t}", .{why}); @@ -1946,49 +1101,23 @@ pub const Session = struct { c.out.deinit(s.gpa); c.mirror.deinit(s.gpa); const gone = s.slotOf(c); - // Which slot this is, so a departing frontend cannot leave `origin` - // pointing at it and send the next `read_clipboard` to a stranger. if (s.origin) |i| if (i == gone) { s.origin = null; }; - // ...and that is the whole of it. A frontend used to take its panes' - // shells with it and leave them owed to whoever attached next, because - // the ptys were in its process; a pane that survived a detach looked - // alive, produced nothing and swallowed everything typed into it. The - // shells are here now, so a frontend leaving is a screen going away and - // nothing else. c.* = .{}; } }; -// --------------------------------------------------------------------------- -// the process -// --------------------------------------------------------------------------- - -/// `pardes --detach[=]`: one core, no terminal, a socket. The loop is the -/// core's own `pump`, exactly as the tty and gui shells run it — this frontend -/// simply has no window of its own. -/// -/// The pre-loop effect drain is here for the same reason tty.zig has one, and -/// it is no longer half a promise: the startup spawns are already queued and are -/// PERFORMED here, on this process's own process table. So a session binds its -/// socket with every pane's shell already forked and already in the poll set, -/// and the first frontend to attach — whether that is a second later or the -/// next morning — is sent a frame of shells that have been printing into the -/// core since before it existed. Nothing is remembered for a later frontend, -/// because nothing is owed to one. pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; options.frame_allocator = allocs.frame; - // The core's own subsystems, not host work: a detached session syntax - // highlights and decodes images exactly like an attached one. pardes.image.start(init.io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -1999,86 +1128,144 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void } const core = if (options.load_path) |lp| blk: { - const bytes = try look.readFile(gpa, lp); + const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); - defer core.deinit(); - var session: Session = .{ .gpa = gpa, + .worker_gpa = allocs.lsp, .io = init.io, .core = core, .cols = options.cols, .rows = options.rows, - // Staged before the first fork and owned by the Session for exactly as - // long as it can fork: `shell_bin.resolve` hands a child pointers into - // these buffers, and the child holds them until it execs. - // - // `prepareForFork` and not `PromptRcs.init` alone: this host forks bash - // through the same `resolve` its siblings do and was the one that never - // silenced Apple's zsh-migration banner, so every pane in a detached - // session on macOS opened with it printed across the top. It also had - // no `adoptSystemPath`, which a daemon needs more than anyone — it is - // the host most likely to be started by launchd. - .prompt_rcs = shell_bin.prepareForFork(), + .prompt_rcs = host_io.Shell.prepare(), }; + defer session.core.deinit(); defer session.deinit(); + try session.initAsync(); if (!session.listen(name)) { - // Loud, and on stderr rather than through the log: a `--detach` whose - // socket did not bind is a session nobody will ever find, and exiting - // is the only honest answer. try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: could not bind a detached session socket\n"); return error.NoSocket; } - // Before the host is installed and before the startup drain, so a script - // that races the daemon's launch finds a tree whose panes already exist. - // Null on every failure — no fuse3, no `user_allow_other`, a kernel without - // FUSE — and a failure must cost the operator their scripting, never their - // session. `fs_service.start` has already said so on pane 0's message row. - // - // NO `fs_service.wake`. That call exists to start a thread that blocks on - // `poll()` and pokes a loop the thread does not otherwise share; this - // process polls `/dev/fuse` itself, in the same syscall as everything else. - // See `Source.fuse`. - session.fs = fs_service.start(gpa, core); - // ...and the same tree on a unix socket, independently: `--fs` and `--fs9` - // are two transports and neither is the other's prerequisite, so a daemon - // may serve one, both or neither. Null on every failure, for - // `fs_service.start`'s reason — a transport that will not bind must cost - // the operator their scripting, never their session — and NOT a - // `return error` the way the frontend socket above is, because a session - // whose frontend socket did not bind is one nobody can ever find, while - // this one is merely one nobody can script over 9P. - // - // A bare `--fs9` is named by the SESSION rather than by the pid: the - // operator typed that name to find the daemon again, and having to look up - // a pid to reach its filesystem would undo it. `--fs9=` wins. - if (opts.fs9) |named| session.ninep = fs9_service.open(gpa, named, name); + session.ninep = ninep_io.listen(gpa, opts.ninep_name, name, opts.ninep_tcp, opts.ninep_quic); + if (session.ninep == null) return error.ListenFailed; + core.fs.socket_path = session.ninep.?.path(); + core.fs.tcp_address = session.ninep.?.tcp_address; + core.fs.quic_address = session.ninep.?.quic_address; const h = session.host(); core.host = h; while (core.nextEffect()) |effect| core.perform(effect); - // Past the startup drain: a `ThemeFile` reload from here on is a human's - // and animates. See `in_loop`. session.in_loop = true; - while (!core.quit) try core.pump(h); + while (!session.core.quit) { + try session.core.pump(h); + if (session.core.quit) break; + if (session.core.takeRestore()) |path| restore: { + const bytes = filesystem.readFile(gpa, path) catch |err| { + session.core.reportError(session.core.active, "Restore", err); + break :restore; + }; + defer gpa.free(bytes); + session.restore(bytes) catch |err| session.core.reportError(session.core.active, "Restore", err); + } + } } -// --------------------------------------------------------------------------- -// the socket, nested.zig's way -// --------------------------------------------------------------------------- +test "detached queued results preserve current requests and are discarded before Restore" { + const gpa = std.testing.allocator; + var s: Session = .{ + .gpa = gpa, + .worker_gpa = gpa, + .io = std.testing.io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .cols = 40, + .rows = 12, + }; + defer s.core.deinit(); + defer s.deinit(); + try s.initAsync(); + while (s.core.nextEffect()) |_| {} + _ = try s.core.setTestFile("saved body\n"); + s.core.lspRequest(0, .status, ""); + const old_id = s.core.lsp_wait.?.id; + s.core.lspRequest(0, .status, ""); + const current_id = s.core.lsp_wait.?.id; + s.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + s.mailbox.post(.{ .lsp = .{ .id = old_id, .rows = try gpa.dupe(u8, "old result\n") } }); + try std.testing.expect(s.drainCompletions(true)); + try std.testing.expectEqual(current_id, s.lsp_task.?.id); + try std.testing.expectEqual(current_id, s.core.lsp_wait.?.id); + + try s.core.dumpState(); + const saved = try gpa.dupe(u8, s.core.dump_out.?); + defer gpa.free(saved); + while (s.core.nextEffect()) |_| {} + s.mailbox.post(.{ .lsp = .{ .id = current_id, .rows = try gpa.dupe(u8, "queued before restore\n") } }); + const outputs = try gpa.alloc([]u8, 1); + outputs[0] = try gpa.dupe(u8, "old filter output\n"); + try std.testing.expect(s.pipe_tasks.add(.{ .id = 77, .future = .{ .any_future = null, .result = {} } })); + s.mailbox.post(.{ .pipe = .{ .id = 77, .success = true, .outputs = outputs } }); + Session.lspStatus(&s, "old status"); + try s.restore(saved); + try std.testing.expect(s.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); + try std.testing.expect(!s.drainCompletions(true)); + try std.testing.expectEqualStrings("saved body\n", s.core.panes[0].?.file.?.content); + + s.core.lspRequest(0, .status, ""); + const restored_id = s.core.lsp_wait.?.id; + try std.testing.expect(restored_id > current_id); + s.lsp_task = .{ .id = restored_id, .future = .{ .any_future = null, .result = {} } }; + s.mailbox.post(.{ .lsp = .{ .id = current_id, .rows = try gpa.dupe(u8, "late old result\n") } }); + _ = s.drainCompletions(true); + try std.testing.expectEqual(restored_id, s.lsp_task.?.id); + try std.testing.expectEqual(restored_id, s.core.lsp_wait.?.id); + s.mailbox.post(.{ .lsp = .{ .id = restored_id, .rows = &.{} } }); + const host = s.host(); + host.vtable.wait_input.?(host.ctx, 1); + try std.testing.expect(s.lsp_task == null); + try std.testing.expect(s.core.lsp_wait == null); +} + +test "detached worker setup failure completes requests without changing document bytes" { + const gpa = std.testing.allocator; + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var s: Session = .{ + .gpa = gpa, + .worker_gpa = failing.allocator(), + .io = std.testing.io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .cols = 40, + .rows = 12, + }; + defer s.core.deinit(); + defer s.deinit(); + try s.initAsync(); + while (s.core.nextEffect()) |_| {} + const pane = try s.core.setTestFile("one\n"); + s.core.host = s.host(); + s.core.lspRequest(0, .status, ""); + while (s.core.nextEffect()) |effect| s.core.perform(effect); + try std.testing.expect(s.core.lsp_wait == null); + try std.testing.expect(s.lsp_task == null); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + s.core.update(.{ .key = .{ .cp = '|' } }); + s.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + s.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + try std.testing.expect(s.core.pipe_wait != null); + while (s.core.nextEffect()) |effect| s.core.perform(effect); + try std.testing.expect(s.core.pipe_wait == null); + try std.testing.expectEqual(@as(usize, 0), s.pipe_tasks.len); + try std.testing.expectEqualStrings("one\n", pane.file.?.content); + try std.testing.expect(failing.has_induced_failure); +} -/// Re-exported so the frontend half of this transport (client.zig) has ONE -/// import for the socket conventions, and so that the file which owns the -/// convention is the file it asks. The definition and its reasoning are -/// nested.zig's. -pub const setCloexec = nested.setCloexec; +pub const setCloexec = ninep_io.setCloexec; -/// Every descriptor in this transport is non-blocking, on both sides: the core -/// must never park on a peer (`waitInput`), and a frontend must never park on -/// the session (client.zig `wait`). `pub` for that second caller. pub fn setNonblock(fd: c_int) void { const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); if (flags < 0) return; @@ -2087,11 +1274,6 @@ pub fn setNonblock(fd: c_int) void { _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); } -/// A dead peer must never kill this process, and that is as true of a frontend -/// whose session ended as of a session whose frontend died — so client.zig -/// takes this one too. linux says it per write, darwin once per socket (see -/// `accept`); the `if (darwin)` is what keeps `MSG.NOSIGNAL`, which darwin's -/// headers do not have, out of that build. pub const nosignal: u32 = if (darwin) 0 else libc.MSG.NOSIGNAL; pub const poll_in: i16 = @intCast(libc.POLL.IN); @@ -2100,35 +1282,17 @@ pub const poll_hup: i16 = @intCast(libc.POLL.HUP); pub const poll_err: i16 = @intCast(libc.POLL.ERR); pub const poll_nval: i16 = @intCast(libc.POLL.NVAL); -/// Give a drained buffer's memory back, and only a big one's: see -/// `idle_retain`. Called where a queue empties rather than on a timer, because -/// that is the one moment the capacity is provably unused. fn retire(gpa: std.mem.Allocator, list: *std.ArrayListUnmanaged(u8)) void { if (list.items.len != 0 or list.capacity <= idle_retain) return; list.clearAndFree(gpa); } -/// Monotonic milliseconds, the clock macos.zig's fling already times with and -/// for its reason: MONOTONIC and not REALTIME, because a handshake that expired -/// because NTP stepped the wall clock backwards is a bug nobody reproduces. -/// -/// Zero on failure, and every caller treats zero as "no clock" and enforces no -/// deadline at all — a session that cannot read a clock keeps every slot rather -/// than dropping every slot. -/// -/// `pub` for the same reason `setNonblock`, `nosignal` and the `poll_*` -/// constants are: this file owns the transport's conventions and BOTH ends of -/// it, and the clock a handshake is timed against is one of them. client.zig -/// times its wait for a `welcome` on this and against -/// `greet_deadline_default_ms`, so the two ends cannot disagree about how long -/// the handshake is allowed to take. pub fn monotonicMs() i64 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); } -/// Sleep, for the one case that has no descriptor to wait on (see `waitInput`). fn nap(ms: u32) void { var ts: libc.timespec = .{ .sec = @intCast(ms / 1000), @@ -2137,14 +1301,7 @@ fn nap(ms: u32) void { _ = libc.nanosleep(&ts, null); } -/// `/pardes-detached-.sock`. The prefix differs from nested.zig's -/// `pardes-.sock` on purpose: that file's sweeper unlinks the socket of any -/// name whose digits name a dead pid, and a session called `work` must never -/// look like one. The buffer is sun_path-sized, so a name that does not fit is -/// no address at all rather than a truncated one pointing somewhere else. pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { - // A name is one path component and nothing clever: a `/` would put the - // socket somewhere else entirely, and a NUL would truncate the address. if (name.len == 0) return null; if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; @@ -2152,78 +1309,33 @@ pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[: const prefix = "pardes-detached-"; -/// The path a FRONTEND connects to for a session called `name`. Derived here -/// rather than in client.zig because this file owns the convention, and the -/// side that binds and the side that connects must not be able to disagree -/// about it. `path_max` is the buffer a caller has to supply. pub const path_max = sun_path_len; pub fn sessionPath(buf: *[path_max]u8, name: []const u8) ?[:0]const u8 { if (comptime !supported) return null; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return null; + const dir = ninep_io.socketDir(&dir_buf) orelse return null; return socketPath(buf, dir, name); } -/// The FRONTEND's half of the vetting this file does before it binds, and the -/// reason it is here rather than in client.zig: one convention, one predicate, -/// one file that owns both. -/// -/// Until this, the server refused a directory anyone else could write and a -/// socket anyone else could talk to, and the client connected to whatever it -/// found at the path it derived — which is the asymmetry this module's header -/// condemns in as many words. A socket planted at a path a frontend derives -/// from `$XDG_RUNTIME_DIR` receives every keystroke that frontend collects, and -/// answers with frames of its choosing. -/// -/// Checked and then connected, in that order, which is a TOCTOU only for -/// somebody who can already write the directory — and the directory is the -/// first thing this refuses. pub fn vetted(path: [:0]const u8) bool { if (comptime !supported) return false; var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return false; - if (!ours(nested.statNoFollow(dir) orelse return false, s_ifdir)) return false; - return ours(nested.statNoFollow(path) orelse return false, s_ifsock); + const dir = ninep_io.socketDir(&dir_buf) orelse return false; + if (!ours(ninep_io.statNoFollow(dir) orelse return false, s_ifdir)) return false; + return ours(ninep_io.statNoFollow(path) orelse return false, s_ifsock); } const s_ifmt: u32 = 0o170000; const s_ifdir: u32 = 0o040000; const s_ifsock: u32 = 0o140000; -/// Is this a `kind` we own, with nothing granted to group or other? The three -/// questions `nested.ensureSocketDir` asks of the directory, asked of the -/// SOCKET too: the two walls are the directory's mode and the file's, and a -/// frontend that checks only one of them has checked neither. -fn ours(st: nested.DirFacts, kind: u32) bool { +fn ours(st: ninep_io.FileFacts, kind: u32) bool { if (st.mode & s_ifmt != kind) return false; if (st.uid != libc.getuid()) return false; return st.mode & 0o077 == 0; } -/// Is something LISTENING at `path`? The one place this file decides whether a -/// socket file is a corpse, asked by `listen` before it takes a name over and -/// by `sweep` before it unlinks anything. -/// -/// nested.zig can ask `kill(0)` because its filenames carry a pid; a detached -/// session is named by a PERSON, so the question is put to the socket: a -/// connect to a bound path with no listener is refused (ECONNREFUSED), and that -/// refusal is the ONLY evidence of death this accepts. Everything else is life, -/// including the case a blocking connect used to turn into a hang — a live -/// session busy inside the core has a full backlog and answers EAGAIN, which is -/// why this socket is NON-BLOCKING. EPERM, a socket() that failed and a path -/// that no longer fits are all "not proven dead" too, and leave the file alone. -/// -/// THE WINDOW THIS CANNOT SEE, stated because it is real: a session between its -/// own `bind` and its `listen(2)` also answers ECONNREFUSED and is alive. It is -/// two syscalls wide, it is only ever entered by another `pardes --detach` -/// starting in the same instant, and what the loser loses is a NAME (its -/// `listen` fails and it says so) rather than a session. Closing it needs a -/// lock file per session, which is a second thing to leak. -/// -/// The successful-connect case costs the live session one slot for one round: -/// closing this descriptor immediately turns the pending connection into an -/// EOF, which `receive` reads as a frontend that left. fn alive(path: [:0]const u8) bool { var addr: libc.sockaddr.un = .{ .path = @splat(0) }; if (path.len + 1 > addr.path.len) return true; @@ -2231,18 +1343,13 @@ fn alive(path: [:0]const u8) bool { const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return true; defer _ = libc.close(fd); - nested.setCloexec(fd); + ninep_io.setCloexec(fd); setNonblock(fd); const rc = libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))); if (rc == 0) return true; return libc.errno(rc) != .CONNREFUSED; } -/// Unlink the sockets of detached sessions that are gone — our own litter, -/// which the bare `Attach`'s "whichever session is there" would otherwise count -/// as a session (client.zig `resolve`). `alive` is the whole of the judgement. -/// -/// Bounded: one readdir of a directory only we write to, one connect each. fn sweep(dir: [:0]const u8) void { const d = libc.opendir(dir) orelse return; defer _ = libc.closedir(d); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 8281fc63..ab19bcfb 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -1,227 +1,28 @@ -//! THE DETACHED-SESSION WIRE FORMAT: one `Event` and one `Host.VTable` call per -//! message, byte for byte, with nothing native about the bytes. -//! -//! WHO OWNS THE CORE. The `Pardes` instance lives in the DETACHED process -//! (server.zig). A frontend (client.zig) owns a terminal and a socket and -//! nothing else: it sends the input it collects and draws the frames it is -//! sent. One core per session, N frontends attached to it, all looking at the -//! same screen — `screen -x`, not N sessions. -//! -//! WHY A CODEC AT ALL, when nested.zig's socket carries a builtin command line -//! and has nothing to version: a command line cannot carry a frame, and frames -//! and input are this transport's entire content. -//! -//! ARCHITECTURE-NEUTRAL, and not as decoration: the frontend on the other end -//! may be riscv32-freestanding (the ESP32-P4 board) while the core is x86_64 -//! linux. So: -//! * every integer is an explicit width, little-endian. No `usize` reaches -//! the wire — a pointer-sized field is 4 bytes on the board and 8 here, and -//! every field after it would then be read at the wrong offset. -//! * no native struct is ever blitted. `@bitCast`/`std.mem.asBytes` of a Zig -//! struct puts this compiler's field order and padding on a socket; every -//! field below is written and read by hand. -//! * every union and every enum gets a tag chosen HERE (`ClientTag`, -//! `ServerTag`, `ColorTag`, ...) and never `@intFromEnum` of a core type, -//! so reordering `Event` or `CellStyle.ul` cannot silently redefine the -//! protocol. The mapping switches are exhaustive: adding a variant to the -//! core is a compile error in this file, which is the point of them. -//! * every variable-length payload carries an explicit length prefix, and -//! `max_payload` bounds the lot. This is a parser on a socket: a malformed -//! frame must be REFUSED, never indexed past. -//! * a bool is one byte, 0 or 1. Any other value is a decode error rather -//! than "nonzero is true": a byte this protocol cannot mean is evidence -//! the stream is not the stream it claims to be. -//! * floats travel as their IEEE-754 binary32 bit pattern inside an explicit -//! u32. Both ends agree about binary32; neither agrees about struct layout. -//! -//! BUILD-NEUTRAL for the same reason. `Event.resize.cell_pixels` exists only -//! when native PDF placement is compiled in (pardes.zig `CellPixels`), and a -//! frontend must not have to have been built with the core's options — so it is -//! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. -//! -//! WHAT IS NOT HERE. The seam has twenty-two methods; this carries FIVE of -//! them — `push_present` as `frame`, `push_set_clipboard`, -//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the -//! seventeen it does not are named here with their reasons. The five are -//! spelled out because this arithmetic has now gone stale twice in one day, -//! once when the machine-local eight moved into the daemon and once when -//! `detach` arrived, and a count nobody can check against a list is a comment -//! that rots quietly. -//! * The nine machine-local ones — `push_spawn`, `push_pty_write`, -//! `push_pty_resize`, `push_pty_signal`, `push_write_file`, -//! `push_write_dump`, `push_watch_file`, `push_watch_theme`, -//! `push_dump_themes` — are -//! performed by the detached core ITSELF, through `host_io.zig`. A unix -//! socket means it is on the same machine, so there is no question of -//! whose disk or whose process table is meant, and a pane's shell has to -//! outlive the frontend that asked for it or a detached session is a -//! promise it cannot keep. The `ServerTag` doc below carries the whole of -//! that argument; this line exists so the count at the top of the file -//! agrees with it. -//! * `pull_wait_input` IS the server's poll loop, not a message. -//! * `push_poll_frame` and `push_post_present` carry no information. They are -//! per-frame bookkeeping ticks, and `frame` already arrives exactly once -//! per pump at the same place in the order — a frontend does its per-frame -//! work when a frame lands. Two more messages per frame per client would -//! say nothing the frame does not already say. -//! * `pull_tty_taken` and `pull_gpio_toggle` are answers the CALLER waits -//! for, and `pull_lsp`/`pull_pipe` are work dispatched off the loop. A -//! round trip inside `update` is the one thing this transport must never -//! do: the core would block on a socket, and `pull_wait_input`'s own -//! comment is that it is the only place this process may sleep. The -//! process that owns the core answers all four. -//! * `push_fs_reply` cannot be a broadcast. host.zig's rule is that the -//! transport which asked is the one holding the request; with N frontends, -//! N-1 would receive the answer to a request they never made. So the acme -//! mount stays in the detached process, where the `Event.fs_req` that -//! starts it is raised, and neither half of that pair is on the wire — -//! which is also why `Event.fs_req` has no `ClientTag`. +//! Detached input and frames use fixed-width little-endian fields and explicit tags. +//! Native struct layout never reaches the wire. const std = @import("std"); const pardes = @import("../pardes.zig"); -/// Bumped whenever any layout below changes. Checked on connect and refused -/// loudly (see `Refusal.version`): two builds of pardes are routinely on one -/// machine — `zig build` replaces the binary under a running session — and a -/// frontend decoding another version's frame layout would paint garbage and -/// blame the terminal. -/// -/// 2: the layout did not move, but what a frontend is ALLOWED TO ASK FOR did. -/// A frontend now clamps its window to `max_cols` x `max_rows` instead of -/// sending it raw (client.zig), and a 512x128 grid is a full frame a v1 daemon -/// PANICS encoding — its run length overflowed a u16 by exactly one cell, see -/// `run_max`. A v1 session refused that geometry outright, so nothing was ever -/// lost by refusing the connection instead; a v1 daemon meeting a v2 frontend -/// answers `Refusal.version`, which says so, rather than dying with every pane -/// shell it owns. This is the case the paragraph above is about: `zig build` -/// replaces the binary under a running session. pub const version: u16 = 2; -pub const Error = error{ - /// The message ended inside a field. - Truncated, - /// A length prefix, a run, or a grid dimension larger than this protocol - /// admits. Refused before anything is allocated or indexed. - Overlong, - /// A tag byte no version of this protocol has ever defined. - BadTag, - /// A tag this protocol does define, carrying a value it cannot mean: a - /// 3-in-a-bool, a zero-column resize, a pane past MAX_PANES. - BadValue, - /// The payload was decoded and bytes were left over. A message that says - /// more than its layout has room for is not this message. - Trailing, - /// The encoder ran out of caller-supplied buffer. - NoSpace, -}; - -// --------------------------------------------------------------------------- -// bounds -// --------------------------------------------------------------------------- +pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; -/// The largest grid this protocol carries. `Surface.cols`/`rows` are u16, so -/// these are protocol bounds rather than type bounds, and they exist because -/// `max_payload` below is derived from them: a decoder that accepts 65535 -/// columns accepts a 25 GiB frame prefix. The board's own grid is 56x14 and a -/// terminal's is usually near 200x50. -/// -/// NOT a ceiling above every real display, which is what this comment used to -/// claim: a 4K window at the SDL shell's minimum 8-pixel font is around 768 -/// columns by 216 rows, and a tty on the same screen passes 128 rows at any -/// ordinary line height. Those windows attach at 512x128 and letterbox the -/// rest (client.zig clamps), rather than being refused as they were. Raising -/// the pair instead would have been a bigger change than it looks: `frameBound` -/// stays well inside `max_payload`, but a themed full frame at 512x128 is -/// already ~0.9 MiB against server.zig's 1 MiB `out_backlog`. pub const max_cols: u16 = 512; pub const max_rows: u16 = 128; +pub const max_payload: u32 = 16 << 20; +pub const header_len = 5; // tag:u8, payload length:u32le -/// One cell at its largest: `default` false, a 7-byte grapheme, two rgb colors, -/// the attribute byte, the underline style and the font role. Written as the -/// sum of the fields rather than a number so that adding a field to `Cell` -/// moves it. const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1; - -/// `start:u32 + count:u16`. A run's cost, and therefore the break-even the -/// encoder coalesces against (see `encodeFrame`). -const run_header = 4 + 2; - -/// ...and the longest run that `count:u16` can describe, which is EXACTLY ONE -/// SHORT of the largest grid this protocol carries: `max_cols * max_rows` is -/// 512*128 = 65536, and `maxInt(u16)` is 65535. -/// -/// A full frame of that grid is ONE run over all of it whenever the theme has -/// a background: `render` fills the surface and every pane then repaints its -/// text area, and `Surface.set` clears `default`, so `sendCell`'s `!default` -/// holds for every cell. (Under a theme with `bg = null` — `dark` — untouched -/// body cells stay default and a stretch of six of them breaks the run, so the -/// overflow was theme-dependent as well as geometry-dependent, which is the -/// worst kind of latent.) `encodeFrame`'s `@intCast(run_end - start)` then -/// panicked in a safe build and was illegal behaviour in a fast one — LLVM -/// happens to truncate to zero, which the far side refuses as `BadValue`, but -/// nothing promises that. That grid is what a frontend with a big window now -/// asks for (client.zig clamps to it), so the meeting point went from -/// unreachable to routine, and the encoder splits the run instead. +const run_header = 4 + 2; // start:u32, count:u16 const run_max = std.math.maxInt(u16); - -/// `kind:u8 + cols:u16 + rows:u16 + cursor(6) + nruns:u32`. const frame_head = 1 + 2 + 2 + 6 + 4; -/// The longest legal payload, and therefore the length prefix a decoder will -/// accept before it refuses the stream. Two messages set it: -/// * a full frame of the largest grid, worst case one run per cell: -/// 512*128 * (6 + 20) = 1.6 MiB. -/// * one paste, which the tty frontend already caps at 4 MiB (tty.zig -/// `max_paste_bytes`) on the grounds that anything larger is a mis-click. -/// 16 MiB is past every source file anyone edits in this editor and is still a -/// buffer the receiving side can simply hold. A larger message is not sent and -/// a larger prefix is not read. -pub const max_payload: u32 = 16 << 20; - -/// Every message is `tag:u8, len:u32le, payload[len]`. A u32 because a full -/// frame and a paste both pass 64 KiB; a u16 would have needed the frame split -/// across messages, which is a second framing layer for no gain. -pub const header_len = 5; - -/// Bytes `encodeFrame` may need for this grid, worst case: every cell changed, -/// every cell in a run of its own, every cell at `cell_max`. The server sizes -/// one buffer from this per geometry rather than guessing. +// Worst case: every cell changed, each in its own run. pub fn frameBound(cols: u16, rows: u16) usize { return header_len + frame_head + @as(usize, cols) * @as(usize, rows) * (run_header + cell_max); } -// --------------------------------------------------------------------------- -// tags -// --------------------------------------------------------------------------- - -/// Frontend -> core. Exhaustive on purpose, which is the opposite of -/// fuse.zig's `Opcode`: there, a newer KERNEL adds opcodes and a non-exhaustive -/// enum is the only way to receive one without undefined behaviour. Here both -/// ends are pardes and an unknown tag is not a newer peer — `version` already -/// refused that — so it is a corrupt or hostile stream and must be rejected. -/// `std.enums.fromInt` is how, at the one place a byte becomes a tag. -/// -/// The numbers are the PROTOCOL's, grouped session/input rather than derived -/// from `Event`'s declaration order, so reordering the union changes nothing. -/// -/// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a -/// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window -/// manager produces. Six numbers are missing from the input run — 0x13..0x17 -/// and 0x1e — and the gaps are left rather than tidied away, because -/// renumbering is a change every deployed frontend feels. They were `output`, -/// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the -/// MACHINE-LOCAL host's own reports, which stopped being a frontend's business -/// when the daemon took the disk and the process table (host_io.zig, -/// file_watch.zig). No frontend ever produced one — tty.zig's attached loop -/// swallowed them by name and gui.zig never handed `Input.post` one — and -/// leaving them DECODABLE was not merely dead weight: server.zig's `apply` -/// routes any decoded non-resize event straight into `core.update`, so an -/// attached peer could forge a pane's output, forge an `eof` for a shell that -/// was still running (and unlike the daemon's own `paneEof` the wire path never -/// called `closePty`, so the master stayed open and the shell was orphaned for -/// the life of the session), or replace a pane's text with bytes the next -/// `Save` would write to disk. client.zig's header says a machine-local effect -/// cannot return to the wire; deleting these is what makes that true in BOTH -/// directions instead of only core -> frontend. +// Stable wire numbers; gaps are retired tags. Unknown tags are rejected by the decoder. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, @@ -237,23 +38,7 @@ pub const ClientTag = enum(u8) { pointer_leave = 0x1d, }; -/// Core -> frontend. 0x01..0x0f is the session; 0x10.. is one `push_` method -/// each, in `Host.VTable`'s own order so the two lists can be read side by -/// side. -/// -/// There are only THREE of those left, and which three is the whole design. -/// The daemon performs every effect that needs a disk or a process table -/// itself (see `host_io.zig`): a unix socket means it is on the same machine, -/// so there is no question of whose disk is meant, and a pane's shell has to -/// outlive the frontend that asked for it or a detached session is a promise -/// it cannot keep. What is left on the wire is what a process nobody is -/// looking at genuinely cannot do — put something on THIS human's clipboard, -/// read it back, and open a link in front of the person who clicked it. -/// -/// `detach` is in the SESSION range and not among those three on purpose: it is -/// not an effect the core wants performed, it is the session telling one -/// frontend that it is done. `quit` is its sibling — same shape, opposite -/// meaning about whether anything survives. +// Session control precedes display-local effects. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, @@ -880,15 +665,7 @@ fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i fn clientTag(msg: ClientMsg) ClientTag { return switch (msg) { .event => |ev| switch (ev) { - // SEVEN `Event`s a frontend cannot produce, so no `ClientTag` - // exists for them and this arm is where the compiler says so. - // `fs_req` is the acme mount, raised in the same process that - // answers it. The other six are the machine-local host's own - // reports — a pty's output and its EOF, a language or pipe worker's - // answer, a watched file's new bytes, an animation tick — and after - // the daemon took the disk and the process table every one of them - // is raised by the process that already holds the core. See - // `ClientTag` for what putting them back would let a peer forge. + // Machine-local reports and 9P requests belong to the session owner. .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, inline else => |_, t| @field(ClientTag, @tagName(t)), }, diff --git a/src/dump.zig b/src/dump.zig index f8740cdb..bb692481 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const limits = @import("limits.zig"); +const limits = @import("memory.zig").limits; // scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope // wholesale (ghostty and uucode log there too), and a corrupt dump's parse @@ -74,13 +74,12 @@ pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", - /// non-empty = an output buffer (no file behind the name): the WORD of the - /// command that opened it, plus that command's argument — see - /// output_pane.Origin. A word rather than an integer for the reason every - /// other command in here is a word: reordering builtins.zig stays free, - /// and a dump stays something a person can read. + dirty: bool = false, + // Builtin names remain stable when enum ordinals change. origin: []const u8 = "", origin_arg: []const u8 = "", + mini_source: []const u8 = "", + mini_colors_b64: []const u8 = "", }; pub const ImagePalette = enum { @@ -119,6 +118,8 @@ pub const Column = struct { panes: []const usize = &.{}, }; +pub const Mount = struct { name: []const u8, dial: []const u8 }; + pub const State = struct { magic: []const u8 = magic, version: u32 = version, @@ -128,6 +129,7 @@ pub const State = struct { theme: []const u8 = "dark", columns: []const Column = &.{}, panes: []const Pane = &.{}, + mounts: []const Mount = &.{}, }; pub fn validate(state: State) !void { @@ -136,6 +138,15 @@ pub fn validate(state: State) !void { if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; + if (state.mounts.len > 8) return error.BadDumpMounts; + for (state.mounts, 0..) |mount, i| { + if (mount.name.len == 0 or mount.name.len > 255 or mount.dial.len == 0 or + std.mem.indexOfScalar(u8, mount.dial, 0) != null) return error.BadDumpMounts; + if (std.mem.eql(u8, mount.name, ".") or std.mem.eql(u8, mount.name, "..") or + std.mem.eql(u8, mount.name, "os") or std.mem.eql(u8, mount.name, "self")) return error.BadDumpMounts; + for (mount.name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return error.BadDumpMounts; + for (state.mounts[0..i]) |previous| if (std.mem.eql(u8, mount.name, previous.name)) return error.BadDumpMounts; + } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; @@ -151,6 +162,11 @@ pub fn validate(state: State) !void { return error.BadDumpTagTail; if (pane.file) |file| if (file.origin_arg.len > max_origin_arg) return error.BadDumpOriginArg; + if (pane.file) |file| { + if (file.mini_source.len > 4096 or std.mem.indexOfScalar(u8, file.mini_source, 0) != null or + (file.mini_source.len == 0 and file.mini_colors_b64.len != 0) or + (file.mini_source.len != 0 and !std.mem.eql(u8, file.origin, "Mini"))) return error.BadDumpMini; + } switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, @@ -201,42 +217,37 @@ pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: St try file.writeStreamingAll(io, out.written()); } -pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); - defer gpa.free(bytes); - return readZon(gpa, bytes, path); -} +pub const Parsed = struct { + value: State, + arena: std.heap.ArenaAllocator, + + pub fn deinit(parsed: *Parsed) void { + parsed.arena.deinit(); + } +}; -pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State { +pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !Parsed { + var parsed: Parsed = .{ .value = undefined, .arena = .init(gpa) }; + errdefer parsed.deinit(); + const arena = parsed.arena.allocator(); const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); - return readZonZ(gpa, source, label); -} -pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { - if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) { - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { - log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + parsed.value = if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) + std.zon.parse.fromSliceAlloc(State, arena, source, null, .{ .free_on_error = false }) catch |err| { + if (err == error.ParseZon) log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); + return err; + } + else blk: { + var diag: std.zon.parse.Diagnostics = .{}; + defer diag.deinit(arena); + break :blk std.zon.parse.fromSliceAlloc(State, arena, source, &diag, .{ .free_on_error = false }) catch |err| { + if (err == error.ParseZon) log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; - } - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { - log.err("parse dump {s}: {f}", .{ label, diag }); - return err; }; - errdefer std.zon.parse.free(gpa, state); - try validate(state); - return state; -} - -pub fn free(gpa: std.mem.Allocator, state: State) void { - std.zon.parse.free(gpa, state); + try validate(parsed.value); + return parsed; } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { @@ -276,7 +287,7 @@ test "dump zon roundtrip" { .body = " 1 alpha", .cols = 20, .rows = 5, - .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, + .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64, .dirty = true }, }, }; const col_panes = [_]usize{ 0, 1 }; @@ -292,18 +303,14 @@ test "dump zon roundtrip" { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const source = try out.toOwnedSliceSentinel(0); - defer gpa.free(source); - - var diag: std.zon.parse.Diagnostics = .{}; - defer diag.deinit(gpa); - const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); - defer std.zon.parse.free(gpa, parsed); - try validate(parsed); + var result = try readZon(gpa, out.written(), "roundtrip"); + defer result.deinit(); + const parsed = result.value; try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); try std.testing.expectEqualStrings("dark", parsed.theme); try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); + try std.testing.expect(parsed.panes[1].file.?.dirty); { const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); defer gpa.free(bytes); @@ -316,6 +323,116 @@ test "dump zon roundtrip" { } } +test "omitted dump defaults roundtrip without borrowing input" { + const gpa = std.testing.allocator; + const fixture = + \\.{ + \\ .screen = .{ .cols = 80, .rows = 24 }, + \\ .columns = .{.{ .panes = .{0, 1, 2} }}, + \\ .panes = .{ + \\ .{ .kind = .terminal, .tag = "terminal", .body = "", .terminal = .{} }, + \\ .{ .kind = .file, .tag = "file", .body = "", .file = .{ .path = "file.zig", .content = "const café = 1;" } }, + \\ .{ .kind = .image, .tag = "image", .body = "", .image = .{} }, + \\ }, + \\} + ; + const input = try gpa.dupe(u8, fixture); + defer gpa.free(input); + var parsed = try readZon(gpa, input, "omitted-defaults"); + defer parsed.deinit(); + @memset(input, 'x'); + const value = parsed.value; + try std.testing.expectEqualStrings(magic, value.magic); + try std.testing.expectEqualStrings("dark", value.theme); + try std.testing.expectEqualStrings("", value.topbar); + try std.testing.expectEqual(@as(usize, 0), value.mounts.len); + try std.testing.expectEqual(@as(usize, 3), value.panes.len); + try std.testing.expectEqualStrings("file.zig", value.panes[1].file.?.path); + try std.testing.expectEqualStrings("const café = 1;", value.panes[1].file.?.content); + try std.testing.expect(!value.panes[1].file.?.dirty); + try std.testing.expect(value.panes[2].image.?.ascii); + + var encoded: std.Io.Writer.Allocating = .init(gpa); + defer encoded.deinit(); + try std.zon.stringify.serialize(value, .{}, &encoded.writer); + var again = try readZon(gpa, encoded.written(), "default-roundtrip"); + defer again.deinit(); + try std.testing.expectEqualDeep(value, again.value); +} + +test "dump parser releases its arena at every allocation failure" { + const Check = struct { + fn run(gpa: std.mem.Allocator) !void { + var parsed = try readZon(gpa, + \\.{ + \\ .screen = .{ .cols = 80, .rows = 24 }, + \\ .columns = .{.{ .panes = .{0} }}, + \\ .panes = .{.{ .kind = .file, .tag = "file", .body = "", .file = .{ .content = "owned" } }}, + \\} + , "allocation-cleanup"); + defer parsed.deinit(); + try std.testing.expectEqualStrings("owned", parsed.value.panes[0].file.?.content); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); +} + +test "dump mount validation bounds names ownership inputs and duplicates" { + const panes = [_]Pane{.{ .kind = .file, .tag = "", .body = "", .file = .{} }}; + const ids = [_]usize{0}; + const columns = [_]Column{.{ .panes = &ids }}; + var state: State = .{ + .screen = .{ .cols = 80, .rows = 24 }, + .columns = &columns, + .panes = &panes, + }; + try validate(state); + for ([_]Mount{ + .{ .name = "peer", .dial = "/tmp/peer.sock" }, + .{ .name = "build-1.local", .dial = "tcp!127.0.0.1!5640" }, + }) |mount| { + state.mounts = &.{mount}; + try validate(state); + } + for ([_]Mount{ + .{ .name = "", .dial = "/tmp/peer.sock" }, + .{ .name = ".", .dial = "/tmp/peer.sock" }, + .{ .name = "..", .dial = "/tmp/peer.sock" }, + .{ .name = "os", .dial = "/tmp/peer.sock" }, + .{ .name = "self", .dial = "/tmp/peer.sock" }, + .{ .name = "two/parts", .dial = "/tmp/peer.sock" }, + .{ .name = "two parts", .dial = "/tmp/peer.sock" }, + .{ .name = "peer", .dial = "" }, + .{ .name = "peer", .dial = "unix!/tmp/peer\x00.sock" }, + }) |mount| { + state.mounts = &.{mount}; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + } + const duplicate = Mount{ .name = "peer", .dial = "/tmp/peer.sock" }; + state.mounts = &.{ duplicate, duplicate }; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + var name: [256]u8 = @splat('x'); + state.mounts = &.{.{ .name = name[0..255], .dial = "/tmp/peer.sock" }}; + try validate(state); + state.mounts = &.{.{ .name = &name, .dial = "/tmp/peer.sock" }}; + try std.testing.expectError(error.BadDumpMounts, validate(state)); + const mounts = [_]Mount{ + .{ .name = "a", .dial = "/tmp/a" }, + .{ .name = "b", .dial = "/tmp/b" }, + .{ .name = "c", .dial = "/tmp/c" }, + .{ .name = "d", .dial = "/tmp/d" }, + .{ .name = "e", .dial = "/tmp/e" }, + .{ .name = "f", .dial = "/tmp/f" }, + .{ .name = "g", .dial = "/tmp/g" }, + .{ .name = "h", .dial = "/tmp/h" }, + .{ .name = "i", .dial = "/tmp/i" }, + }; + state.mounts = mounts[0..8]; + try validate(state); + state.mounts = &mounts; + try std.testing.expectError(error.BadDumpMounts, validate(state)); +} + test "version-one image records default old fields and roundtrip new state" { const gpa = std.testing.allocator; const legacy = @@ -335,13 +452,13 @@ test "version-one image records default old fields and roundtrip new state" { \\ }}, \\} ; - const old = try readZon(gpa, legacy, "legacy-image"); - defer free(gpa, old); - const old_image = old.panes[0].image.?; + var old = try readZon(gpa, legacy, "legacy-image"); + defer old.deinit(); + const old_image = old.value.panes[0].image.?; try std.testing.expect(!old_image.petscii); try std.testing.expectEqual(ImagePalette.commodore, old_image.palette); try std.testing.expect(old_image.ascii); - try std.testing.expect(old.panes[0].tag_tail == null); + try std.testing.expect(old.value.panes[0].tag_tail == null); const pane = Pane{ .kind = .image, @@ -366,14 +483,14 @@ test "version-one image records default old fields and roundtrip new state" { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - const parsed = try readZon(gpa, out.written(), "new-image"); - defer free(gpa, parsed); - const restored = parsed.panes[0].image.?; + var parsed = try readZon(gpa, out.written(), "new-image"); + defer parsed.deinit(); + const restored = parsed.value.panes[0].image.?; try std.testing.expect(restored.petscii); try std.testing.expectEqual(ImagePalette.terminal, restored.palette); try std.testing.expect(!restored.ascii); - try std.testing.expect(parsed.panes[0].tag_tail != null); - try std.testing.expectEqualStrings("", parsed.panes[0].tag_tail.?); + try std.testing.expect(parsed.value.panes[0].tag_tail != null); + try std.testing.expectEqualStrings("", parsed.value.panes[0].tag_tail.?); } test "validation bounds pane restore state" { diff --git a/src/effect_sources.zig b/src/effect_sources.zig index d4f0e38b..3ca15c3f 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -1,100 +1,18 @@ -//! Build-embedded source behind EffectCode. -//! -//! Several builtins intentionally share one shader pass. Returning segments -//! makes that sharing visible instead of copying or manufacturing a pretend -//! per-effect program. - const std = @import("std"); const build_config = @import("pardes_config"); -const runtime_config = @import("runtime_config.zig"); - -const panel_math = @embedFile("panel_animation.zig"); -const pardes_core = @embedFile("pardes.zig"); -const tty_compositor = @embedFile("tty/panel_compositor.zig"); -const panel_vertex = @embedFile("effect-source-ui.vert.glsl"); -const panel_fragment = @embedFile("effect-source-ui.frag.glsl"); -const image_vertex = @embedFile("effect-source-image.vert.glsl"); -const image_fragment = @embedFile("effect-source-image.frag.glsl"); -const scene_vertex = @embedFile("effect-source-crt.vert.glsl"); -const scene_fragment = @embedFile("effect-source-crt.frag.glsl"); -const gui_scene_mapping = @embedFile("gui/crt.zig"); -const gui_host = @embedFile("gui/gui.zig"); -const mac_scene = @embedFile("effect-source-crt.ci.metal"); -const mac_postprocessor = @embedFile("macos/Sources/ScenePostprocessor.swift"); -const mac_view = @embedFile("macos/Sources/PardesView.swift"); - -fn sourceSection( - comptime source: []const u8, - comptime begin_marker: []const u8, - comptime end_marker: []const u8, -) []const u8 { - // gui.zig is intentionally embedded as the source actually compiled, then - // narrowed to its marked host paths. The comptime byte scan is larger than - // Zig's small default quota but contributes no runtime work. - @setEvalBranchQuota(1_000_000); - const begin = std.mem.indexOf(u8, source, begin_marker) orelse - @compileError("EffectCode source begin marker is missing"); - const body = begin + begin_marker.len; - const end = std.mem.indexOfPos(u8, source, body, end_marker) orelse - @compileError("EffectCode source end marker is missing"); - return source[body..end]; -} +const config = @import("config.zig"); +const filesystem = @import("fs.zig"); -const gui_panel_host = sourceSection( - gui_host, - "// EFFECT_CODE_PANEL_HOST_BEGIN\n", - "// EFFECT_CODE_PANEL_HOST_END", -); -const gui_native_panel = sourceSection( - gui_host, - "// EFFECT_CODE_NATIVE_PANEL_BEGIN\n", - "// EFFECT_CODE_NATIVE_PANEL_END", -); -const gui_frame_submission = sourceSection( - gui_host, - "// EFFECT_CODE_FRAME_SUBMISSION_BEGIN\n", - "// EFFECT_CODE_FRAME_SUBMISSION_END", -); -const gui_cell_instance = sourceSection( - gui_host, - "// EFFECT_CODE_CELL_INSTANCE_BEGIN\n", - "// EFFECT_CODE_CELL_INSTANCE_END", -); -const ascii_diff = sourceSection( - pardes_core, - "// EFFECT_CODE_ASCII_DIFF_BEGIN\n", - "// EFFECT_CODE_ASCII_DIFF_END", -); -const ascii_compositor = sourceSection( - pardes_core, - " // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN\n", - " // EFFECT_CODE_ASCII_COMPOSITOR_END", -); - -const ui_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.vert.glsl" -else - "shaders/ui.vert.glsl"; -const ui_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.frag.glsl" -else - "shaders/ui.frag.glsl"; -const image_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/image.vert.glsl" -else - "shaders/image.vert.glsl"; -const image_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/image.frag.glsl" +const shader_dir = if (build_config.gui_shader_sources_prebuilt) + "shaders/prebuilt/" else - "shaders/image.frag.glsl"; -const scene_vertex_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/crt.vert.glsl" -else - "shaders/crt.vert.glsl"; -const scene_fragment_path = if (build_config.gui_shader_sources_prebuilt) - "shaders/prebuilt/crt.frag.glsl" -else - "shaders/crt.frag.glsl"; + "shaders/"; +const ui_vertex_path = shader_dir ++ "ui.vert.glsl"; +const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; +const image_vertex_path = shader_dir ++ "image.vert.glsl"; +const image_fragment_path = shader_dir ++ "image.frag.glsl"; +const scene_vertex_path = shader_dir ++ "crt.vert.glsl"; +const scene_fragment_path = shader_dir ++ "crt.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -121,120 +39,72 @@ pub fn guiShaderSourceMode() ?GuiShaderSourceMode { .live; } -pub const Segment = struct { - path: []const u8, - source: []const u8, +pub const files = switch (backend) { + .tty => [_]filesystem.Source{ + .{ .path = "src/tty/panel_compositor.zig", .contents = @embedFile("tty/panel_compositor.zig") }, + }, + .gui => [_]filesystem.Source{ + .{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") }, + .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") }, + .{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") }, + .{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") }, + .{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") }, + .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, + .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") }, + .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.frag.glsl") }, + }, + .macos => [_]filesystem.Source{ + .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, + .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .contents = @embedFile("macos/Sources/ScenePostprocessor.swift") }, + .{ .path = "shaders/crt.ci.metal", .contents = @embedFile("effect-source-crt.ci.metal") }, + }, + .web, .esp32p4 => [_]filesystem.Source{}, }; -const tty_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/tty/panel_compositor.zig", .source = tty_compositor }, -}; -const gui_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/gui/gui.zig#panel-host", .source = gui_panel_host }, - .{ .path = "src/gui/gui.zig#native-panel", .source = gui_native_panel }, - .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission }, - .{ .path = "src/gui/gui.zig#cell-instance", .source = gui_cell_instance }, - .{ .path = ui_vertex_path, .source = panel_vertex }, - .{ .path = ui_fragment_path, .source = panel_fragment }, - .{ .path = image_vertex_path, .source = image_vertex }, - .{ .path = image_fragment_path, .source = image_fragment }, -}; -const gui_scene = [_]Segment{ - .{ .path = "src/gui/crt.zig", .source = gui_scene_mapping }, - .{ .path = "src/gui/gui.zig#frame-submission", .source = gui_frame_submission }, - .{ .path = scene_vertex_path, .source = scene_vertex }, - .{ .path = scene_fragment_path, .source = scene_fragment }, -}; -const mac_panel = [_]Segment{ - .{ .path = "src/panel_animation.zig", .source = panel_math }, - .{ .path = "src/pardes.zig#ascii-diff", .source = ascii_diff }, - .{ .path = "src/pardes.zig#ascii-compositor", .source = ascii_compositor }, - .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view }, - .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor }, - .{ .path = "shaders/crt.ci.metal", .source = mac_scene }, -}; -const mac_scene_segments = [_]Segment{ - .{ .path = "src/macos/Sources/PardesView.swift", .source = mac_view }, - .{ .path = "src/macos/Sources/ScenePostprocessor.swift", .source = mac_postprocessor }, - .{ .path = "shaders/crt.ci.metal", .source = mac_scene }, -}; - -/// Sources for the backend this executable was built for. Keeping the backend -/// compile-time and out of the call signature is important: a TTY binary must -/// not carry the macOS host and Metal source merely because another switch arm -/// could have returned them. -pub fn forSetting(setting: runtime_config.Setting) ?[]const Segment { +pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { return switch (setting.action) { .transition => switch (backend) { - .tty => &tty_panel, - .gui => &gui_panel, - .macos => &mac_panel, + .tty => &.{ "src/layout.zig", "src/pardes.zig", "src/tty/panel_compositor.zig" }, + .gui => &.{ "src/layout.zig", "src/pardes.zig", "src/gui/gui.zig", ui_vertex_path, ui_fragment_path, image_vertex_path, image_fragment_path }, + .macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .web, .esp32p4 => null, }, .scene => switch (backend) { - .gui => &gui_scene, - .macos => &mac_scene_segments, + .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path }, + .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, }, else => null, }; } -test "every current-backend effect exposes embedded implementation code" { - for (runtime_config.settings) |setting| switch (setting.action) { - .transition => if (backend != .web) { - const segments = forSetting(setting) orelse return error.MissingTransitionSource; - for (segments) |segment| { - try std.testing.expect(segment.path.len > 0); - try std.testing.expect(segment.source.len > 0); - } - } else try std.testing.expect(forSetting(setting) == null), - .scene => if (backend == .gui or backend == .macos) { - const segments = forSetting(setting) orelse return error.MissingSceneSource; - for (segments) |segment| { - try std.testing.expect(segment.path.len > 0); - try std.testing.expect(segment.source.len > 0); - } - } else try std.testing.expect(forSetting(setting) == null), - else => {}, - }; -} - -test "GUI scene EffectCode includes both runtime shader stages" { - if (comptime backend == .gui) { - try std.testing.expectEqual(@as(usize, 4), gui_scene.len); - try std.testing.expectEqualStrings("src/gui/crt.zig", gui_scene[0].path); - try std.testing.expectEqualStrings("src/gui/gui.zig#frame-submission", gui_scene[1].path); - try std.testing.expectEqualStrings(scene_vertex_path, gui_scene[2].path); - try std.testing.expectEqualStrings(scene_fragment_path, gui_scene[3].path); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[0].source, "pub fn mapScene") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[1].source, "SDL_PushGPUFragmentUniformData") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[2].source, "gl_VertexIndex") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_scene[3].source, "u_crt.effects") != null); +test "EffectCode paths resolve uniquely to this backend's archived inputs" { + for (config.Runtime.settings) |setting| { + const available = switch (setting.action) { + .transition => backend == .tty or backend == .gui or backend == .macos, + .scene => backend == .gui or backend == .macos, + else => false, + }; + const paths = forSetting(setting); + try std.testing.expectEqual(available, paths != null); + if (paths) |listed| for (listed, 0..) |path, i| { + const bytes = filesystem.sourceBytes(path) orelse return error.MissingEffectSource; + try std.testing.expect(bytes.len > 0); + for (listed[0..i]) |previous| try std.testing.expect(!std.mem.eql(u8, path, previous)); + }; } -} - -test "GUI panel EffectCode includes the actual host paint and submission path" { - if (comptime backend == .gui) { - try std.testing.expect(std.mem.indexOf(u8, ascii_diff, "pub const PanelCellDiff") != null); - try std.testing.expect(std.mem.indexOf(u8, ascii_compositor, "fn composeAsciiTransitions") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn makePaintPlan") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_panel_host, "fn setTransitionFields") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn prepareNativeImages") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "fn drawNativeImagesGpu") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_native_panel, "active.visualBox()") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "for (paint_plan.batches") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "fn emitInstance") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_cell_instance, "setTransitionFields") != null); - try std.testing.expect(std.mem.indexOf(u8, panel_math, "frame_count") != null); - try std.testing.expect(std.mem.indexOf(u8, gui_frame_submission, "active.effect == .dissolve") != null); + for (files) |file| { + const archived = filesystem.sourceBytes(file.path) orelse return error.MissingEffectSource; + try std.testing.expectEqualStrings(file.contents, archived); + var matches: usize = 0; + for (filesystem.sources) |source| if (std.mem.eql(u8, source.path, file.path)) { + matches += 1; + }; + try std.testing.expectEqual(@as(usize, 1), matches); } + try std.testing.expectEqual(backend == .tty, filesystem.sourceBytes("src/tty/panel_compositor.zig") != null); + try std.testing.expectEqual(backend == .gui, filesystem.sourceBytes("src/gui/gui.zig") != null); + try std.testing.expectEqual(backend == .macos, filesystem.sourceBytes("src/macos/Sources/PardesView.swift") != null); } test "shader source provenance is GUI-only and follows the build input" { @@ -247,22 +117,3 @@ test "shader source provenance is GUI-only and follows the build input" { try std.testing.expect(expected.label().len > 0); } else try std.testing.expect(guiShaderSourceMode() == null); } - -test "mac EffectCode includes the runtime panel and scene wiring" { - if (comptime backend == .macos) { - // `extern "C" [[stitchable]]` is what the runtime compile requires: - // CIKernel.kernels(withMetalString:) looks for stitchable functions and - // rejects the whole source without them, so ScenePostprocessor.init? - // returns nil and every scene effect and panel transition silently - // degrades to the plain CoreText draw. The spelling is pinned on - // purpose; relaxing these three matches is how that comes back. - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanelClear") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesPanel") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "extern \"C\" [[stitchable]] float4 pardesScene") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_scene, "already composed in Pardes core") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_view, "hideCursor: !tracks.isEmpty") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelClearKernel.apply") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "panelKernel.apply") != null); - try std.testing.expect(std.mem.indexOf(u8, mac_postprocessor, "sceneKernel.apply") != null); - } -} diff --git a/src/esp32p4.zig b/src/esp32p4.zig index 8eab57e2..0d532e1e 100644 --- a/src/esp32p4.zig +++ b/src/esp32p4.zig @@ -1,8 +1,7 @@ //! The ESP32-P4 firmware shell: pardes as one freestanding object, bytes in and bytes out. //! -//! This is the fourth platform, and the only one that is not an executable. `zig build -//! -Dplatform=esp32p4 -Dtarget=riscv32-freestanding` emits this file as a single object exporting the C -//! ABI below; the `zig-p4` package links it beside its own `_start`, its generated linker script, +//! `zig build -Dplatform=esp32p4` emits this file as a single object exporting the C +//! ABI below; the sibling `05-zig-p4` toolchain links it beside `_start`, its generated linker script, //! and its UART driver. Nothing here knows what a UART is. //! //! **Why an object and not a module.** The obvious arrangement was for zig-p4 to declare this @@ -123,7 +122,7 @@ pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) c /// /// The board's side, not the editor's, because a correct toggle is the IO MUX, the GPIO matrix, the /// pad's own bits and the output enable - four register files behind a per-pin table that the -/// firmware already has and checks against ESP-IDF. See `Host.VTable.pull_gpio_toggle`. +/// firmware already has and checks against ESP-IDF. See `Host.VTable.gpio_toggle`. pub const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool; // ------------------------------------------------------------------- the allocator, rebuilt @@ -280,7 +279,7 @@ export fn pardes_esp32p4_init( .y_pixel = 0, }; - const allocs = pardes.allocators.init(a); + const allocs = pardes.memory.init(a); // `std.Io.failing` and not a real Io: every path in the core that would perform I/O is behind // the Host vtable, and the ones that are not are the ones this platform does not have. pardes.image.start(std.Io.failing, allocs.image); @@ -608,7 +607,7 @@ export fn pardes_esp32p4_quit() callconv(.c) bool { // ------------------------------------------------------------------------------------ the host -const pardes_host: pardes.Host.VTable = .{ .push_present = present, .pull_gpio_toggle = gpioToggle }; +const pardes_host: pardes.Host.VTable = .{ .present = present, .gpio_toggle = gpioToggle }; /// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and /// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message diff --git a/src/esp32p4/app.zig b/src/esp32p4/app.zig index a9cf627d..ae5679f3 100644 --- a/src/esp32p4/app.zig +++ b/src/esp32p4/app.zig @@ -17,7 +17,7 @@ //! What stayed behind is everything a second application would also want, and none of it is //! duplicated here: the SoC and HAL, the translate-c register layer, the coalescing heap, `std.Io` //! for this chip, the app descriptor, the generated linker script, the image builder, the flasher -//! and the interactive console. Those arrive as the `zig_p4` dependency, and this file imports +//! and the interactive console. The sibling `05-zig-p4` build supplies them; this file imports //! exactly four of its modules - `soc`, `hal`, `heap` and `config` - plus two sibling files, //! `uart.zig` and `input_rescue.zig`, which are the editor's own. //! @@ -33,8 +33,7 @@ //! give the firmware two ways to reach the editor. And above all it would make the OBJECT path a //! second arrangement, tested separately: that path is what `05-zig-p4 -Dpardes -Dpardes-obj=...` //! builds, it is what every measurement in that repository's `experiments/` was taken through, and -//! it is a supported way to build this board. With the extern kept, both builds link the same eight -//! symbols against the same object file, so neither can drift and neither is the better-tested one. +//! it is the firmware build path. Its externs link against that object file. //! The reasons the seam is a file at all - a nested `build.zig.zon` dependency broke every build in //! the toolchain repository - are recorded in `src/esp32p4.zig:8-15` and `05-zig-p4/build.zig:238-260`. //! diff --git a/src/esp32p4/input_rescue.zig b/src/esp32p4/input_rescue.zig index 01dd4820..3f1c0268 100644 --- a/src/esp32p4/input_rescue.zig +++ b/src/esp32p4/input_rescue.zig @@ -37,10 +37,8 @@ //! same-named module of `zig build selftest`'s on-die root (`:437-438`). Both spell //! `../02-pardes-code/src/esp32p4/input_rescue.zig`, so there is nothing to keep in step. //! -//! Tested from here, both ways: the host checks at the bottom run as their own `addTest` under -//! `zig build unit-test` (`02-pardes-code/build.zig:1727-1732` - no `link_libc`, because `std` is -//! the whole import list), and the same source runs against UART0 on the die under -//! `zig build esp32p4-test`. +//! Host checks run under `zig build unit-test` here. In `../05-zig-p4`, +//! `zig build selftest` flashes and runs the on-die checks against UART0. const std = @import("std"); diff --git a/src/esp32p4/selftest.zig b/src/esp32p4/selftest.zig index 0d692d98..97b43059 100644 --- a/src/esp32p4/selftest.zig +++ b/src/esp32p4/selftest.zig @@ -35,22 +35,18 @@ //! //! Four of its modules and no more: `soc` (mask-ROM printf, the cycle counter), `hal` (UART0, the //! systimer, clock/reset), `config` (this build's `cpu_mhz`) and `heap` (the coalescing allocator). -//! They arrive as the `zig_p4` dependency, which also supplies what makes this an image at all - -//! the generated linker script, `ENTRY(_start)` and the app descriptor via `firmware(...).attach`, -//! then `ImageStep`, `FlashStep` and `SelftestStep`. +//! The sibling `05-zig-p4` build supplies these modules and the firmware image tooling. //! //! `input_rescue` is the fifth import and is NOT that package's: it is the sibling file in this //! directory, handed over as a named MODULE rather than imported as a path. Deliberately so - the //! `pub` on `FakePort`'s methods below is what lets that module reach them by duck typing across the -//! boundary, and both builds, this repository's `esp32p4-test` and the toolchain's `selftest`, wire -//! the identical root the identical way. One file, one arrangement, nothing to drift. +//! boundary. The toolchain's `selftest` step wires this root and that module together. //! //! Not a `zig test` binary, deliberately. Zig's test runner wants an OS, and `std.testing.allocator` //! is a debug allocator over the page allocator, which on freestanding is either a compile error or //! a lie. A hand-rolled harness is thirty lines and answers to nobody. //! -//! Run with: zig build esp32p4-test -Dplatform=esp32p4 -Desp32p4-firmware (from here) -//! or: zig build selftest (from ../05-zig-p4) +//! From `../05-zig-p4`, `zig build selftest` flashes and runs this suite on hardware. const std = @import("std"); const soc = @import("soc"); diff --git a/src/esp32p4/uart.zig b/src/esp32p4/uart.zig index 53ee29df..15599423 100644 --- a/src/esp32p4/uart.zig +++ b/src/esp32p4/uart.zig @@ -1,4 +1,4 @@ -//! UART0 as the editor's terminal: bytes out, bytes in, and nothing else. +//! UART0 transport for the editor and standalone GPIO 9P firmware. //! //! This is the whole of the firmware's I/O. There is no framebuffer and no keyboard; the board //! emits ANSI and consumes ANSI, and the terminal emulator on the far end of the CH340 does the @@ -37,13 +37,8 @@ //! with nothing readable left to explain it. Everything else here touches FIFO offset 0x000 and the //! status register, and nothing else. //! -//! The DIVIDER is the one exception, and it was carved out for the second image rather than for this -//! one: `docs/registry.typ` `BOARD-1`. The editor's console is opened by a human at 115200 and the -//! firmware inherits that divider (which is why the paragraph above used to say "not the divider"); -//! the 9P image (`nine.zig`) has a program on the far end that opens the port at whatever rate the -//! image was built for, and eight times the baud is eight times less latency on every `Tread`. So -//! `setBaud` exists, this file still never calls it, and `app.zig` still never calls it — the only -//! caller is the image whose host side is opened to match. +//! The editor inherits the bootloader's 115200 divider. `src/esp32p4_9p.zig` +//! calls `setBaud` for its 921600 protocol connection; the host must match it. const hal = @import("hal"); const input_rescue = @import("input_rescue.zig"); diff --git a/src/esp32p4_9p.zig b/src/esp32p4_9p.zig index dd0346ef..4aa559a1 100644 --- a/src/esp32p4_9p.zig +++ b/src/esp32p4_9p.zig @@ -1,149 +1,13 @@ -//! THE BOARD AS A 9P SERVER, and nothing else: the reset entry, one UART, and a pump. -//! -//! This is the SECOND ESP32-P4 image and it is not a second role for the first one. `app.zig` is -//! the editor — a real `pardes.Pardes` core with vaxis on top, emitting ANSI down UART0 to a -//! terminal emulator on the far end. This image links none of that. Same board, same UART, same -//! flash partition, one at a time, because the editor owns UART0 bidirectionally and JP1 exposes no -//! second P4 UART (`docs/registry.typ` `9P-11`: "The board is either an editor or a filesystem at -//! any one time. Say that plainly rather than implying both"). -//! -//! THE UART CARRIES ONLY 9P. That is the whole difference from the other image and it is the point. -//! No ANSI, no vaxis, no escape sequences, no `MARK` boot markers, no `soc.rom.print` — not even -//! the heap report `app.zig:320-329` prints on every boot, which would be the single most useful -//! line here and is still not allowed, because a byte on this wire that is not part of a 9P message -//! is a byte that desynchronises whatever is parsing it. The proof that this image booted is that it -//! answers `Tversion`. -//! -//! The one thing that had to be said in some other language is a PANIC and a TRAP, and they are said -//! in 9P too: an `Rerror` carrying the message, tagged `NOTAG`. No client is waiting for that tag, -//! so `9p` reports it as an unexpected reply and prints the string — which is exactly the diagnosis -//! wanted ("the board died, here is why") delivered without putting one non-protocol byte on the -//! wire. See `panicImpl` and `trapReport`. -//! -//! ## What it serves -//! -//! `src/board9p.zig`, which is the board's own capabilities as a tree: `gpio/pinout` is the JP1 -//! drawing the editor's `Gpio` word prints, and `gpio//value` is one pad's driven level, readable -//! and writable. Both come out of a comptime table, and adding a capability to that table adds files -//! here with no code in this file changing at all. -//! -//! Deliberately NOT `src/acmefs.zig`, and the reason is the same one that makes this a second image. -//! That file is the EDITOR's control filesystem: every operation in it is about a pane, and a pane -//! only exists because a `pardes.Pardes` exists. Serving it would mean linking the editor object -//! (809,536 B of image) and instantiating the core, at which point this is `app.zig` with a -//! different output encoding rather than a 9P server. It compiles for this target — `llvm-nm` finds -//! 21,548 B of `acmefs.*` in `zig-out/pardes-esp32p4.o` — and that fact is what made this image -//! worth building, because it is what proved the filesystem layer has no host dependency. The ABI is -//! what got reused, not the tree: `src/9p.zig`'s `Server` is a generic over the filesystem, and -//! `board9p` implements `acmefs`'s `Op`/`Status`/`Req`/`Reply` verbatim, so the same server serves -//! either one and neither knows about the other. -//! -//! ## The loop -//! -//! Four lines, and every one of them is a `Server` method doing what its doc comment says: -//! -//! read bytes off the UART -> srv.push(bytes) -//! pump -> srv.retry() / srv.next() -> fsys.handle(req) -> srv.reply(...) -//! write what is queued -> srv.wrote(uart.writeSome(srv.output())) -//! -//! NOTHING BLOCKS. `uart.read` is non-blocking, `uart.writeSome` hands over what the transmit FIFO -//! has room for and answers how much, and `Server.wrote(n)` takes a partial write as an ordinary -//! answer rather than an error (`src/9p.zig:2248-2257`). So a client that stops reading cannot stall -//! this loop, and a reply larger than the 128-byte FIFO leaves over several trips round it. That is -//! the same sans-io contract `src/fs9_service.zig` gives the desktop's unix socket; the difference -//! is that there is no `poll` here and no need for one, because there is exactly one connection and -//! it is the wire. -//! -//! ## The numbers, measured rather than costed -//! -//! `.bss` IS THE WHOLE RAM BILL, because this image has no allocator: not a heap, not an arena, and -//! the 384 KiB span the editor's image hands `heapmod` is not even mapped by anything here. So the -//! board's ≈336 KB of free heap (`docs/registry.typ` `FIX-2`) is untouched at 100%, and what this -//! program spends is the 240 KiB of low L2MEM that `9P-11`'s built note names as the real binding -//! constraint. `llvm-size` on the ELF says `.bss` is 16,656 B, and every byte of it is accounted -//! for: -//! -//! 9,192 `srv` — `Server(Tree(Pads))` on riscv32. `9P-11` measured 9,488 on the -//! host; a 32-bit target's slices are half the width, and the park -//! table has thirty-two of them. -//! 1,024 `in_buf` — one msize -//! 2,048 `out_buf` — two, so no reply can fail to be queued -//! 4,108 the rescue ring — `input_rescue.Ring` inside `uart.zig`, which comes with the UART -//! 148 `fsys` — the whole tree: one 143-byte answer buffer and a counter -//! 128 `stage` -//! ------ -//! 16,648 + 8 of alignment and `uart.dropped` = 16,656 -//! -//! Add the 32,768-byte `.stack` the shared linker script gives every image built through -//! `firmware()` and the low-L2MEM total is 49,424 B, 20% of the 240 KiB — against the editor's -//! 75,236 B (20,408 `.data` + 22,060 `.bss` + the same stack). The stack is the largest single item -//! and it is inherited rather than chosen: 32 KiB is sized for the CORE's recursive layout pass -//! (`build.zig:1088-1090`), and nothing in this image recurses at all. -//! -//! FLASH: the image is 88,080 B of the 1,536,000 B partition — 5.7%, against the editor image's -//! 812,688 B (52.9%). Only 28,066 B of that is content (22,504 `.flash.text`, 5,562 B of real -//! `.flash.rodata`, 80 B of image header and checksum); the rest is the gap between the end of the -//! rodata segment and the 64 KiB-aligned origin the code segment must start on, because the ESP32 -//! flash MMU maps in 64 KiB pages and the two segments cannot share one. A tiny image pays up to -//! 64 KiB for that and there is nothing to be done about it here — it is the generated linker -//! script's arithmetic (`05-zig-p4/build.zig`), and it is why the estimate of "≈39 KiB" in `9P-11` -//! was closer to the CONTENT than to the image. -//! -//! ## Build it, flash it, talk to it -//! -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-flash -//! zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-size # no board needed -//! -//! There is no `esp32p4-9p-attach`, and that absence is the design: what belongs on the far end of -//! this wire is a 9P client opened at `baud`, not a terminal. `9p` and `9pfuse` speak to a SOCKET, -//! so reaching this board with either means a program that copies bytes between the tty and a unix -//! socket in both directions — which is nine lines of anything and is not this file's business. -//! pardes's own client (`src/fs9_client.zig`) needs no such bridge, because a tty is already a -//! bidirectional byte stream and that is all 9P has ever asked for (`docs/registry.typ` `9P-19`). -//! -//! FLASHING THIS REPLACES THE EDITOR. Both images are written to `img.opts`'s one offset, on -//! purpose: there is one partition and the board is one thing at a time. `zig build esp32p4-flash` -//! puts the editor back. - +//! Standalone GPIO 9P firmware over UART0; the editor is not linked. +//! UART0 carries protocol bytes only, including fatal diagnostics. const std = @import("std"); const soc = @import("soc"); const hal = @import("hal"); const config = @import("config"); -// PATH imports, not named modules, and that is what lets any builder root an -// image here: the toolchain repository links this file with the four platform -// modules it owns (`soc`, `hal`, `config`, `heap`) and nothing else, so a -// `@import("ninep")` here was a module only pardes's own build.zig knew to -// inject — and the image stopped building the moment that build.zig stopped -// linking it. See `src/board9p.zig`'s note on the same change. const ninep = @import("9p.zig"); -const board9p = @import("board9p.zig"); +const gpio = @import("esp32p4_gpio.zig"); const uart = @import("esp32p4/uart.zig"); -/// THE PADS, and this is the whole seam between the tree and the silicon. -/// -/// The same four `hal.gpio` calls `src/esp32p4/app.zig:200-209` makes for the editor's `Gpio` word, -/// for the reason that file gives at length: a toggle is not a write to GPIO_OUT. `configureOutput` -/// points the pad's IO MUX at the GPIO function, routes the GPIO matrix's output to it, sets the -/// drive strength and input buffer, clears the pulls and only then enables the driver — four register -/// files indexed by a per-pin table, which live in the toolchain package where `zig build diff` -/// checks their numbers against ESP-IDF's own headers. A second copy would be a second copy under no -/// test. This is a second CALLER, which is the opposite thing. -/// -/// `getDrivenLevel` and not `getLevel`: the answer is the level this board is DRIVING, which is -/// defined for every pin including one with nothing attached, where the pad's own level is whatever -/// the air says. `readback = true` enables the input buffer anyway, so a client that wants the pad -/// rather than the register has something to compare against. -/// -/// SPLIT INTO `level` AND `drive` rather than the editor's single `toggle`, because a file can say -/// which level it wants and a keystroke cannot. `Gpio 20` has one argument and has to mean "the -/// other one"; `echo 1 > gpio/20/value` says 1, which is what makes it idempotent and therefore -/// scriptable. Writing the level a pad is already at still calls `configureOutput`, and that is not -/// a wasted write: on a freshly booted board it is the call that makes the pad an output at all. -/// -/// BOTH ARE `pub` AND HAVE TO BE, for the same reason `src/esp32p4/selftest.zig:44-46` says its -/// `FakePort`'s methods are: `board9p` is a MODULE here, and duck typing across a module boundary -/// still needs the declaration to be visible from outside the file it is in. Nothing else in this -/// image is `pub`. const Pads = struct { pub fn level(pin: u8) u1 { return hal.gpio.getDrivenLevel(pin); @@ -156,132 +20,58 @@ const Pads = struct { }; comptime { - // Every pin the tree generates has to be a pad this chip package has, and the check belongs here - // rather than in `board9p.zig`: `max_pin` is 56 on this package and lives in the toolchain - // repository, which a host-testable tree cannot import. A JP1 row edited to name GPIO 60 is a - // compile error in this image instead of an out-of-bounds register index on the die. - for (board9p.pins) |pin| { + for (gpio.Header.gpio_pins) |pin| { if (pin > hal.gpio.max_pin) @compileError("JP1 names a pad this chip package does not have"); } } -/// The board's tree, over the real pads. -const Fs = board9p.Tree(Pads); -const Server = ninep.Server(Fs); +const Fs = gpio.Fs(Pads); +const Server = ninep.Server(Fs, ninep.board_fids); + +comptime { + std.debug.assert(@typeInfo(@FieldType(Server, "fids")).array.len == 32); + std.debug.assert(@sizeOf(Server) <= 10 * 1024); +} -/// THE msize, and it is 1,024 rather than the 4,096 everything else in this tree assumes. -/// -/// The 4,096 floor is the LINUX KERNEL's and nobody else's: `linux/net/9p/client.c:840-843` refuses -/// to mount below it, which is why `9p.min_msize` is 4,096 and why the desktop daemon serves that. -/// Plan 9's devmnt, plan9port's `9p` and pardes's own client all accept 512 -/// (`docs/registry.typ` `9P-11`), and no Linux kernel is ever going to mount this image: the far end -/// of this wire is a serial port, and a `mount -t 9p` needs a socket or a virtio channel, neither of -/// which a CH340 is. So the floor that applies here is `9p.msize_min` — 217 bytes, DERIVED from the -/// largest reply whose size the client does not choose (`src/9p.zig:1873-1881`). -/// -/// 1,024 and not 217, because the number to size against is the widest DIRECTORY READ. `gpio/` has -/// twelve entries, a `stat` record in a directory read is 49 bytes of fixed fields plus the name plus -/// three copies of the client's `uname` (`src/9p.zig:3251-3260`), so a `goblin` reading `ls gpio/` -/// wants 12 × ~73 = ~880 bytes to get the listing in ONE round trip. At 217 it would take five, and -/// each one costs a `Tread` and an `Rread` on a wire. Everything else here is tiny: the largest file -/// in the tree is the 468-byte JP1 drawing and the largest write is two bytes. -/// -/// What it costs: `in` is one msize and `out` is two — one message going out and one being built, -/// which is what makes every reply in the server infallible — so 3,072 B for the buffers against -/// 12,288 B at a 4,096 msize. Nine kilobytes of the board's low L2MEM for a round trip nobody needs. const msize: u32 = 1024; -/// One whole T-message, and the ceiling on the msize this connection will agree to. var in_buf: [msize]u8 = undefined; -/// Two, for the reason above. `Server.hasRoom` reserves one msize before it hands any request to the -/// filesystem, which is what makes back-pressure land on `next()` returning null instead of on a -/// half-written reply. var out_buf: [2 * msize]u8 = undefined; -/// Bytes off the receiver on their way into the server, and the ONE buffer in this file. -/// -/// 128 is the transmit and receive FIFO depth (the toolchain package's `src/hal/uart.zig:52`), so one -/// `uart.read` can never leave more behind than one FIFO's worth, and the tail that `push` would not -/// take is re-offered next time round the loop. It is not a reassembly buffer — `Server.in` is that, -/// and it holds a whole message — it is the handover between a driver that fills a slice and a server -/// that takes what it has room for. var stage: [128]u8 = undefined; -/// The wire's rate, and the host must be opened to match or nothing works and nothing says so. -/// -/// 921600 rather than the 115200 the bootloader leaves behind: `docs/registry.typ` `BOARD-1`. One -/// `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL, int 43 frag 6, +0.064% error, and it takes -/// a byte from 86.8 µs to 10.85 µs — which on this loop is a warm `cat gpio/20/value` going from -/// 10.8 ms to 1.35 ms and a 1 KiB `Tread` from 89 ms to 11 ms. 2 Mbaud is representable and this -/// CH340 is unreliable there, corroborated by the flasher's own choice at `build.zig:1136-1138`. -/// -/// It is programmed before the first reply and after the input drain, which is the one moment when -/// there can be nothing in either FIFO to be corrupted by the change. const baud: u32 = 921600; -/// The server and the tree, both in `.bss` and both fixed for the life of the image. No allocator -/// exists in this program at all — not a heap, not an arena, not the `heapmod` the editor's image -/// hands over 384 KiB to — so `zig build esp32p4-9p-size` reporting `.bss` is reporting the whole -/// of what this server costs in RAM. var srv: Server = undefined; var fsys: Fs = .{}; export fn zig_main() noreturn { - // FIRST, before anything reads `.rodata`, exactly as `app.zig:275` does it and for the same - // reason: the JP1 drawing this image serves is 468 bytes of `.rodata` in flash, and a read of it - // through a stale cache returns whatever was there at reset. soc.flushFlashCache(); - // The same clock the editor's image runs at, so a latency measured on one is a latency on the - // other. A divider change that disturbs neither UART0 (XTAL) nor the flash interface (SPLL). if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) { 180 => .mhz180, 360 => .mhz360, else => .mhz90, }); - // The RTC watchdog is armed at reset and this loop never feeds anything. Without this the board - // resets a few seconds in, which over a wire that carries only 9P looks exactly like a client - // that cannot reach it. _ = hal.rwdt.disable(); - // WHAT THE BOOTLOADER LEFT ON THE WIRE, discarded before the divider changes: its own chatter - // has already been echoed at the host, and the host bridge injects a synthetic window-size - // report before this program exists. Neither is 9P, and either would be the first bytes of a - // message that never was. _ = uart.drainInput(); - // The rate, then. A refusal is not fatal and must not be: an unreachable divider leaves 115200 - // in place, which is a slow board rather than a silent one, and a client opened at the wrong rate - // finds out immediately because `Tversion` gets no answer it can parse. _ = uart.setBaud(baud); - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); - // THE PUMP. `stage_len` is the only state outside the server. var stage_len: usize = 0; while (true) { - // IN. Non-blocking, rescued bytes first (`uart.read`), and never more than the staging - // buffer's room, so a burst larger than one FIFO simply arrives over two iterations. if (stage_len < stage.len) stage_len += uart.read(stage[stage_len..]); if (stage_len != 0) { - // A SHORT PUSH IS NORMAL AND IS NOT A LOSS: it is the only back-pressure a sans-io - // server has (`src/9p.zig:2229-2233`). What it would not take stays here and is offered - // again after the pump has made room by finishing a message. const took = srv.push(stage[0..stage_len]); if (took != stage_len) std.mem.copyForwards(u8, stage[0 .. stage_len - took], stage[took..stage_len]); stage_len -= took; } - // PUMP, in the order `src/fs_service.zig:209-222` requires: every parked request offered - // once, then everything the wire has, both loops to null. - // - // NOTHING ON THIS BOARD PARKS — the answer to "what level is this pad" is a register read, - // and there is no `event` file and no reader to block — so `retry()` answers null on the - // first call, every time. It is here because the contract is the contract, and because the - // first capability that does block (an interrupt-driven `gpio//edge`) needs this line to - // already exist rather than to be remembered. while (srv.retry()) |req| { const a = fsys.handle(req); srv.reply(&a.reply, a.bytes); @@ -291,18 +81,9 @@ export fn zig_main() noreturn { srv.reply(&a.reply, a.bytes); } - // OUT. Whatever fits in the transmitter right now, and the server keeps the rest. const queued = srv.output(); if (queued.len != 0) srv.wrote(uart.writeSome(queued)); - // THE STREAM WAS NOT 9P, and there is no resynchronising from that: a `size` no encoder - // could have produced, an R-message from something that thought it was the server, a - // message larger than the negotiated msize. On a socket the answer is to close the - // connection and let the client notice; on a wire that cannot be closed, the answer is to - // reset it — pay the filesystem whatever `release`s the dead fids owe it, throw away every - // byte in flight in both directions, and start a fresh connection in the same silence a - // reboot would have. A client resynchronises by sending `Tversion`, which is what a client - // does after any failure anyway. if (srv.dead) { srv.hangup(); while (srv.next()) |req| { @@ -311,25 +92,11 @@ export fn zig_main() noreturn { } _ = uart.drainInput(); stage_len = 0; - srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root }); + srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = gpio.root }); } } } -// --------------------------------------------------------------------------- dying in protocol - -/// A message this image is about to die with, as an `Rerror` on `NOTAG`. -/// -/// THE ONE PLACE A NON-REPLY IS SENT, and it is still a legal 9P message, which is the whole trick. -/// `NOTAG` is the tag of the `Tversion` exchange and no client has a request outstanding under it, so -/// `9p` and pardes's own client both report an unexpected reply AND PRINT THE STRING — "the board -/// panicked at 0x4000a1b8", delivered through a parser rather than past it. The alternative is what -/// the editor's image does, `MARK PARDES_PANIC` in plain text, which on this wire would be a frame -/// header of 0x4b52414d followed by garbage: an unrecoverable stream instead of a diagnosis. -/// -/// Blocking `uart.write` and not `writeSome`, because there is no loop left to come back round: this -/// is the last thing the image does, and a bounded spin that gets the whole message out is worth -/// more here than one that returns. fn die(msg: []const u8) noreturn { var buf: [ninep.errmax + ninep.header_len + 2]u8 = undefined; const bytes = ninep.encode( @@ -341,9 +108,6 @@ fn die(msg: []const u8) noreturn { while (true) {} } -/// Eight hex digits into `buf`, computed arithmetically. Hand-rolled rather than `std.fmt`, for the -/// reason `uart.dumpWord` gives: this runs in a trap handler, where the less of the image it depends -/// on the more likely it is to run at all. fn hex8(buf: *[8]u8, v: u32) void { var shift: u5 = 28; for (buf) |*slot| { @@ -353,11 +117,6 @@ fn hex8(buf: *[8]u8, v: u32) void { } } -/// `mtvec` is set in DIRECT mode by `_start`, so every trap and every interrupt lands here. -/// -/// A trap handler exists for the reason `app.zig:432-441` gives — the mask ROM's "Guru Meditation" -/// only prints while ITS handler is installed, and a silent fault over a serial line is -/// indistinguishable from an infinite loop — and it reports through 9P for the reason `die` gives. export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ("j trapReport"); } @@ -372,8 +131,6 @@ export fn trapReport() noreturn { const mtval = asm volatile ("csrr %[o], mtval" : [o] "=r" (-> u32), ); - // The three registers that name a RISC-V fault, in the order a reader wants them: what happened, - // where, and to which address. var msg = "trap mcause=00000000 mepc=00000000 mtval=00000000".*; hex8(msg[12..20], mcause); hex8(msg[26..34], mepc); @@ -381,21 +138,6 @@ export fn trapReport() noreturn { die(&msg); } -// --------------------------------------------------------------- the root's own duties -// -// This is a ROOT, so it owns std's configuration for this compilation unit. The editor's image has -// two of these (`app.zig` and `src/esp32p4.zig`, one per object); this image is one object and has -// one. - -/// `page_size_min`/`max`: no MMU and no pages here, but std derives alignment from them, and 4 KiB -/// is this chip's cache and DMA granularity. -/// -/// `logFn` is not cosmetic and it is not optional. std's default log implementation reaches -/// `std.debug_io`, which instantiates `std.Io.Threaded` — a thread pool, `getrandom`, `IOV_MAX`, -/// `mremap` — and one `log.warn` anywhere in the graph drags all of it into the image. This one -/// DISCARDS, which is the only honest thing it can do: there is nowhere for a log line to go on a -/// wire that carries only 9P, and a log line that went out anyway would break the connection it was -/// trying to explain. Nothing in this image's graph logs; this is the wall that keeps it that way. pub const std_options: std.Options = .{ .page_size_min = 4096, .page_size_max = 4096, @@ -412,9 +154,6 @@ fn logFn( pub const panic = std.debug.FullPanic(panicImpl); fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { - // The address is what makes it actionable — `addr2line` against the ELF in zig-out turns it into - // a source line — so it goes in front of the message, where `errmax`'s 128-byte truncation - // cannot reach it. A panic message names a KIND of failure; the address names which one. var buf: [ninep.errmax]u8 = undefined; @memcpy(buf[0..7], "panic 0"); buf[7] = 'x'; @@ -425,14 +164,6 @@ fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn { die(buf[0 .. 17 + n]); } -/// Reset entry, identical in shape to `app.zig:528-546` and for the identical reasons: the bootloader -/// hands over with an unspecified stack pointer and the FPU off, so enable the F extension -/// (`mstatus.FS`), establish a stack, install the trap vector, clear `.bss`, and jump into Zig. -/// -/// `.bss` MATTERS MORE HERE THAN ANYWHERE. Everything this image owns is in it — the server, its two -/// buffers, the tree, the staging buffer — so this loop is what makes the fid table empty and the -/// msize zero, and skipping it would start the server mid-connection with a client that does not -/// exist. export fn _start() linksection(".text.entry") callconv(.naked) noreturn { asm volatile ( \\ li t0, 1 << 13 diff --git a/src/esp32p4_gpio.zig b/src/esp32p4_gpio.zig new file mode 100644 index 00000000..92468581 --- /dev/null +++ b/src/esp32p4_gpio.zig @@ -0,0 +1,576 @@ +//! ESP32-P4 JP1 GPIO data and its freestanding filesystem. +const std = @import("std"); + +// JP1: JC-ESP32P4-M3-DEV schematic, sheet 2 "Expand IO": +// 01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png +pub const Header = struct { + pub const Pad = union(enum) { + none, + gpio: u8, + // C6 and power nets are not P4 GPIOs; the schematic gives no GPIO numbers for ES_I2C. + net: []const u8, + + fn label(comptime p: Pad) []const u8 { + return switch (p) { + .none => "--", + .gpio => |n| std.fmt.comptimePrint("GPIO {d}", .{n}), + .net => |s| s, + }; + } + }; + + pub const Row = struct { left: Pad, right: Pad }; + + pub const rows = [13]Row{ + .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, + .{ .left = .{ .net = "3V3" }, .right = .{ .net = "5V" } }, + .{ .left = .{ .net = "GND" }, .right = .{ .net = "GND" } }, + .{ .left = .{ .gpio = 1 }, .right = .none }, + .{ .left = .{ .gpio = 2 }, .right = .{ .gpio = 47 } }, + .{ .left = .{ .gpio = 3 }, .right = .{ .gpio = 46 } }, + .{ .left = .{ .gpio = 4 }, .right = .{ .gpio = 45 } }, + .{ .left = .{ .gpio = 5 }, .right = .{ .net = "GND" } }, + .{ .left = .{ .gpio = 20 }, .right = .{ .net = "3V3" } }, + .{ .left = .{ .gpio = 32 }, .right = .{ .net = "C6_U0RXD" } }, + .{ .left = .{ .gpio = 33 }, .right = .{ .net = "C6_U0TXD" } }, + .{ .left = .{ .net = "ES_I2C_SDA" }, .right = .{ .net = "C6_IO9" } }, + .{ .left = .{ .net = "ES_I2C_SCL" }, .right = .{ .net = "C6_CHIP_PU" } }, + }; + + const row_format = "{s:>10} | {d:>2} | {d:>2} | {s}\n"; + + const border = " +---------+\n"; + + pub const text = rendered: { + var out: []const u8 = + \\JP1 header - 26 pins, pin 1 top left. + \\Every number here is DECIMAL. + \\ + \\ + ; + out = out ++ border; + for (rows, 0..) |row, i| out = out ++ std.fmt.comptimePrint( + row_format, + .{ row.left.label(), 2 * i + 1, 2 * i + 2, row.right.label() }, + ); + break :rendered out ++ border ++ + \\ + \\Gpio flips one: 0->1 or 1->0. + \\ + ; + }; + + pub const gpio_pins = pins: { + var found: [2 * rows.len]u8 = undefined; + var n: usize = 0; + for (rows) |row| for ([2]Pad{ row.left, row.right }) |p| switch (p) { + .gpio => |g| { + found[n] = g; + n += 1; + }, + else => {}, + }; + std.mem.sort(u8, found[0..n], {}, std.sort.asc(u8)); + break :pins found[0..n].*; + }; + + test "the rendered header is the drawing the console has always printed" { + try std.testing.expectEqualStrings( + \\JP1 header - 26 pins, pin 1 top left. + \\Every number here is DECIMAL. + \\ + \\ +---------+ + \\ 3V3 | 1 | 2 | 5V + \\ 3V3 | 3 | 4 | 5V + \\ GND | 5 | 6 | GND + \\ GPIO 1 | 7 | 8 | -- + \\ GPIO 2 | 9 | 10 | GPIO 47 + \\ GPIO 3 | 11 | 12 | GPIO 46 + \\ GPIO 4 | 13 | 14 | GPIO 45 + \\ GPIO 5 | 15 | 16 | GND + \\ GPIO 20 | 17 | 18 | 3V3 + \\ GPIO 32 | 19 | 20 | C6_U0RXD + \\ GPIO 33 | 21 | 22 | C6_U0TXD + \\ES_I2C_SDA | 23 | 24 | C6_IO9 + \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU + \\ +---------+ + \\ + \\Gpio flips one: 0->1 or 1->0. + \\ + , text); + } + + test "the header's own GPIOs, and only those" { + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 5, 20, 32, 33, 45, 46, 47 }, &gpio_pins); + try std.testing.expectEqual(Pad.none, rows[3].right); + try std.testing.expectEqualStrings("C6_IO9", rows[11].right.net); + } +}; + +pub const E = struct { + pub const NOENT: u16 = 2; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; +}; + +pub const root: u64 = 1; +const gpio_node: u64 = 16; +const pinout_node: u64 = 17; + +const Entry = struct { + node: u64, + parent: u64, + name: []const u8, + kind: enum { directory, pinout, value }, + pin: u8 = 0, +}; + +const entries = table: { + var result: [3 + 2 * Header.gpio_pins.len]Entry = undefined; + result[0] = .{ .node = root, .parent = root, .name = "/", .kind = .directory }; + result[1] = .{ .node = gpio_node, .parent = root, .name = "gpio", .kind = .directory }; + result[2] = .{ .node = pinout_node, .parent = gpio_node, .name = "pinout", .kind = .pinout }; + for (Header.gpio_pins, 0..) |pin, i| { + const directory = 18 + i; + result[3 + 2 * i] = .{ + .node = directory, + .parent = gpio_node, + .name = std.fmt.comptimePrint("{d}", .{pin}), + .kind = .directory, + }; + result[4 + 2 * i] = .{ + .node = (2 + i) * 16 + 1, + .parent = directory, + .name = "value", + .kind = .value, + .pin = pin, + }; + } + break :table result; +}; + +const dirent_fixed = 10; +const out_capacity = size: { + var result: usize = 2; + for (entries) |directory| { + if (directory.kind != .directory) continue; + var bytes: usize = 0; + for (entries) |entry| { + if (entry.parent == directory.node and entry.node != directory.node) + bytes += dirent_fixed + entry.name.len; + } + result = @max(result, bytes); + } + break :size result; +}; + +pub fn Fs(comptime Pads: type) type { + return struct { + const Self = @This(); + pub const name_capacity = 28; + + pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir }; + + pub const Status = enum(u8) { ok, again, err }; + + pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + }; + + pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o600, + }; + + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } + }; + + // Reply bytes are borrowed until the next handle call. + pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; + + out: [out_capacity]u8 = undefined, + + fn find(node: u64) ?*const Entry { + for (&entries) |*entry| if (entry.node == node) return entry; + return null; + } + + fn attributes(entry: *const Entry) Reply.Attr { + return .{ + .name = entry.name, + .node = entry.node, + .dir = entry.kind == .directory, + .mode = switch (entry.kind) { + .directory => 0o500, + .pinout => 0o400, + .value => 0o600, + }, + .size = switch (entry.kind) { + .directory => 0, + .pinout => Header.text.len, + .value => 2, + }, + }; + } + + pub fn handle(fs: *Self, req: Req) Answer { + const entry = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; + switch (req.op) { + .lookup => { + if (entry.kind != .directory) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + if (std.mem.eql(u8, req.data, "..")) + return .{ .reply = .{ .tag = req.tag, .attr = attributes(find(entry.parent).?) } }; + for (&entries) |*child| { + if (child.parent != req.node or child.node == req.node) continue; + if (!std.mem.eql(u8, child.name, req.data)) continue; + return .{ .reply = .{ .tag = req.tag, .attr = attributes(child) } }; + } + return .{ .reply = .fail(req.tag, E.NOENT) }; + }, + .getattr => return .{ .reply = .{ .tag = req.tag, .attr = attributes(entry) } }, + .setattr => { + // GPIO register views have no storage to truncate. + if (entry.kind == .directory) return .{ .reply = .fail(req.tag, E.INVAL) }; + return .{ .reply = .{ .tag = req.tag, .attr = attributes(entry) } }; + }, + .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, + .release => return .{ .reply = .{ .tag = req.tag } }, + .read => { + const bytes = switch (entry.kind) { + .directory => return .{ .reply = .fail(req.tag, E.INVAL) }, + .pinout => Header.text, + .value => value: { + fs.out[0] = '0' + @as(u8, Pads.level(entry.pin)); + fs.out[1] = '\n'; + break :value fs.out[0..2]; + }, + }; + const off: usize = @intCast(@min(req.off, bytes.len)); + return .{ .reply = .{ .tag = req.tag }, .bytes = bytes[off..][0..@min(bytes.len - off, req.size)] }; + }, + .write => { + if (entry.kind != .value or req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; + const value = std.mem.trimEnd(u8, req.data, "\r\n"); + if (value.len != 1 or (value[0] != '0' and value[0] != '1')) + return .{ .reply = .fail(req.tag, E.INVAL) }; + Pads.drive(entry.pin, @intCast(value[0] - '0')); + return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } }; + }, + .readdir => { + if (entry.kind != .directory) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + var len: usize = 0; + var skip = req.off; + for (entries) |child| { + if (child.parent != req.node or child.node == req.node) continue; + if (skip != 0) { + skip -= 1; + continue; + } + std.mem.writeInt(u64, fs.out[len..][0..8], child.node, .little); + fs.out[len + 8] = @intFromBool(child.kind == .directory); + fs.out[len + 9] = @intCast(child.name.len); + @memcpy(fs.out[len + dirent_fixed ..][0..child.name.len], child.name); + len += dirent_fixed + child.name.len; + } + return .{ .reply = .{ .tag = req.tag }, .bytes = fs.out[0..len] }; + }, + } + } + }; +} + +const testing = std.testing; + +const StubPads = struct { + var driven: [64]u1 = @splat(0); + var log: [16]Call = undefined; + var log_len: usize = 0; + + const Call = struct { pin: u8, level: u1 }; + + fn reset() void { + driven = @splat(0); + log_len = 0; + } + + fn level(pin: u8) u1 { + return driven[pin]; + } + + fn drive(pin: u8, want: u1) void { + driven[pin] = want; + log[log_len] = .{ .pin = pin, .level = want }; + log_len += 1; + } +}; + +const Board = Fs(StubPads); + +fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { + var at: u64 = root; + var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; + for (path) |name| { + const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); + if (a.reply.status == .err) return switch (a.reply.errno) { + E.NOENT => error.NoEntry, + E.NOTDIR => error.NotDirectory, + else => error.Refused, + }; + attr = a.reply.attr; + at = attr.node; + } + return attr; +} + +fn readAll(t: *Board, node: u64) !Board.Answer { + const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Board.Status.ok, open.reply.status); + return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); +} + +test "GPIO: the generated tree has exactly the header's pins, and nothing else" { + var t: Board = .{}; + + try testing.expect((try walk(&t, &.{"gpio"})).dir); + try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); + + for (Header.gpio_pins, 0..) |pin, i| { + var name: [4]u8 = undefined; + const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); + const directory = try walk(&t, &.{ "gpio", dir }); + try testing.expect(directory.dir); + try testing.expectEqual(@as(u64, 18 + i), directory.node); + const value = try walk(&t, &.{ "gpio", dir, "value" }); + try testing.expect(!value.dir); + try testing.expectEqual(@as(u64, (2 + i) * 16 + 1), value.node); + try testing.expectEqual(@as(u16, 0o600), value.mode); + } + + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); + try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); +} + +test "GPIO: a read of gpio/pinout is the bytes the Gpio word draws" { + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "pinout" }); + const a = try readAll(&t, at.node); + try testing.expectEqualStrings(Header.text, a.bytes); + try testing.expectEqual(Header.text.len, at.size); + try testing.expectEqual(@as(u16, 0o400), at.mode); + const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); + try testing.expectEqual(E.INVAL, w.reply.errno); +} + +test "GPIO: writing 1 then 0 drives the pad twice, through the seam" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "20", "value" }); + + const before = try readAll(&t, at.node); + try testing.expectEqualStrings("0\n", before.bytes); + + const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); + try testing.expectEqual(Board.Status.ok, one.reply.status); + try testing.expectEqual(@as(u32, 1), one.reply.written); + try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); + + const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); + try testing.expectEqual(@as(u32, 2), zero.reply.written); + try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); + + try testing.expectEqual(@as(usize, 2), StubPads.log_len); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); +} + +test "GPIO: a pad takes 0 and 1 and refuses everything else, without touching the pads" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "45", "value" }); + + for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { + const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); + try testing.expectEqual(Board.Status.err, a.reply.status); + try testing.expectEqual(E.INVAL, a.reply.errno); + } + try testing.expectEqual(@as(usize, 0), StubPads.log_len); + + const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); + try testing.expectEqual(E.INVAL, off.reply.errno); + try testing.expectEqual(@as(usize, 0), StubPads.log_len); +} + +test "GPIO: node parents are explicit, not derived from node bits" { + for (&entries) |*e| { + const serial = e.node >> 4; + const file = e.node & 0xF; + const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; + if (derived == e.parent) continue; + try testing.expectEqualStrings("value", e.name); + var t: Board = .{}; + const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); + try testing.expectEqual(E.NOENT, a.reply.errno); + } +} + +test "GPIO: parent lookup follows every generated directory" { + var t: Board = .{}; + for (&entries) |*entry| { + if (entry.kind != .directory) continue; + const answer = t.handle(.{ .tag = 1, .op = .lookup, .node = entry.node, .data = ".." }); + try testing.expectEqual(Board.Status.ok, answer.reply.status); + try testing.expectEqual(entry.parent, answer.reply.attr.node); + try testing.expect(answer.reply.attr.dir); + } +} + +test "GPIO: a directory read lists what the table generated, in table order" { + var t: Board = .{}; + + try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); + try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); + + const gpio = (try walk(&t, &.{"gpio"})).node; + const listing = try names(&t, gpio, 0); + try testing.expectEqual(Header.gpio_pins.len + 1, listing.len); + try testing.expectEqualStrings("pinout", listing[0]); + for (Header.gpio_pins, 0..) |pin, i| { + var buf: [4]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); + } + + const rest = try names(&t, gpio, 5); + try testing.expectEqual(Header.gpio_pins.len + 1 - 5, rest.len); + try testing.expectEqualStrings("5", rest[0]); +} + +var name_slots: [32][]const u8 = undefined; +fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { + const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); + try testing.expectEqual(Board.Status.ok, a.reply.status); + var n: usize = 0; + var i: usize = 0; + while (i < a.bytes.len) { + const len = a.bytes[i + 9]; + name_slots[n] = a.bytes[i + 10 ..][0..len]; + n += 1; + i += 10 + len; + } + return name_slots[0..n]; +} + +test "GPIO: the whole tree costs one buffer, and the table says how big" { + try testing.expectEqual(@as(usize, 143), out_capacity); + try testing.expectEqual(@as(usize, 143), @sizeOf(Board)); + try testing.expectEqual(@as(usize, 28), Board.name_capacity); + try testing.expect(Header.text.len > out_capacity); +} + +test "GPIO: stat and truncation preserve the register value and reads respect offsets" { + StubPads.reset(); + var fs: Board = .{}; + const value = try walk(&fs, &.{ "gpio", "20", "value" }); + for (0..2) |_| { + const written = fs.handle(.{ .tag = 1, .op = .write, .node = value.node, .data = "1\r\n" }); + try testing.expectEqual(@as(u32, 3), written.reply.written); + } + try testing.expectEqual(@as(usize, 2), StubPads.log_len); + const stat = fs.handle(.{ .tag = 2, .op = .getattr, .node = value.node }); + try testing.expectEqual(@as(u64, 2), stat.reply.attr.size); + const truncated = fs.handle(.{ .tag = 3, .op = .setattr, .node = value.node, .truncate = true }); + try testing.expectEqual(Board.Status.ok, truncated.reply.status); + try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); + const part = fs.handle(.{ .tag = 4, .op = .read, .node = value.node, .off = 1, .size = 1 }); + try testing.expectEqualStrings("\n", part.bytes); + const eof = fs.handle(.{ .tag = 5, .op = .read, .node = value.node, .off = 2, .size = 1 }); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + const pinout = try walk(&fs, &.{ "gpio", "pinout" }); + const text = fs.handle(.{ .tag = 6, .op = .read, .node = pinout.node, .off = 10, .size = 12 }); + try testing.expectEqualStrings(Header.text[10..22], text.bytes); +} + +const ninep = @import("9p.zig"); + +test "GPIO: a real 9P client reads a pin's value off this tree" { + StubPads.reset(); + const Server = ninep.Server(Board, ninep.board_fids); + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var fsys: Board = .{}; + var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); + + var scratch: [256]u8 = undefined; + const send = struct { + fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { + const bytes = try ninep.encode(msg, tag, buf); + try testing.expectEqual(bytes.len, s.push(bytes)); + while (s.retry()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + while (s.next()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + } + }.call; + const reap = struct { + fn call(s: *Server) !ninep.Decoded { + const queued = s.output(); + const len = ninep.frameLen(queued) orelse return error.NoReply; + const got = try ninep.decode(queued[0..len]); + s.wrote(len); + return got; + } + }.call; + + try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); + const v = try reap(&srv); + try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); + + try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); + try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); + + var wname: [ninep.max_welem][]const u8 = @splat(""); + wname[0] = "gpio"; + wname[1] = "20"; + wname[2] = "value"; + try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); + try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); + + try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); + _ = try reap(&srv); + + try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); + try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); + try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); + + try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); + try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); + + try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); + try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); + try testing.expectEqual(@as(usize, 1), StubPads.log_len); +} diff --git a/src/file_pane.zig b/src/file_pane.zig deleted file mode 100644 index 9b828030..00000000 --- a/src/file_pane.zig +++ /dev/null @@ -1,1059 +0,0 @@ -//! File panes: everything a Pane does BECAUSE it has `file: ?State` set — the -//! disk read, the content swap undo/redo commits through, the tree-sitter -//! highlight window, and the two render passes only a file has (the line -//! number gutter and the syntax recolor). The rest of a file pane's behaviour -//! is the pane machinery in pardes.zig, which does not care what kind it is. -const std = @import("std"); -const vaxis = @import("vaxis"); -const pardes = @import("pardes.zig"); -const config = @import("config.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const modal = @import("modal.zig"); -const look = @import("look.zig"); -const output_pane = @import("output_pane.zig"); -const syntax = @import("syntax.zig"); -const tracy = @import("tracy.zig"); -const term_pane = @import("term_pane.zig"); -const dump = @import("dump.zig"); -const limits = @import("limits.zig"); - -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; - -/// Content and primary selection at one file edit boundary. Keeping only the -/// primary avoids putting pardes.MAX_SELS ranges in every history entry. -pub const Snapshot = struct { - content: []u8, - cur_row: i32, - cur_col: i32, - vsel: pardes.CharSel, -}; - -/// A file pane's backing: owned content, its derived caches, and undo history. -pub const State = struct { - path: []u8, - content: []u8, - /// Monotonic content identity for asynchronous edits. Every content swap - /// goes through setContent, which bumps this; a pipe completion accepted - /// against another revision would overwrite intervening work. - revision: u32 = 0, - /// Revision last known to match disk, after Save or an external reload. - /// Equal means the screen matches disk. - saved_revision: u32 = 0, - /// Non-null for a generated output buffer rather than an on-disk file. - output: ?output_pane.Output = null, - scroll: usize = 0, - /// `line_starts[i]` is line i's byte offset. Empty means not built yet. - line_starts: []usize = &.{}, - /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte. - highlights: []u8 = &.{}, - highlight_start: usize = 0, - syntax_dirty: bool = true, - undo: [limits.undo_max]Snapshot = undefined, - undo_len: usize = 0, - redo: [limits.undo_max]Snapshot = undefined, - redo_len: usize = 0, -}; - -/// Serialize file-owned bytes and identity; output origin vocabulary is -/// supplied by output_pane at the core dispatch edge. -pub fn dumpPane( - arena: std.mem.Allocator, - pane: *const Pane, - file: *const State, - tag: []const u8, - body: []const u8, - scroll: usize, - origin: []const u8, - origin_arg: []const u8, -) !dump.Pane { - return .{ - .kind = .file, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .file = .{ - .path = file.path, - .content = file.content, - .content_b64 = try dump.encodeBytes(arena, file.content), - .origin = origin, - .origin_arg = origin_arg, - }, - }; -} - -pub fn graphemeDisplayWidth(grapheme: []const u8) usize { - if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; - // A one-byte printable ASCII grapheme is one cell, and saying so here rather than asking - // `gwidth` costs a comparison instead of a Unicode table walk. `gwidth` was 6.9% of a profiled - // keystroke at the P4's geometry, essentially all of it answering this question about `y`. - // Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell, and - // `gwidth` is still the authority on them. - if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); -} - -pub fn byteDisplayWidth(byte: u8) usize { - return if (byte == '\t') config.tab_width else 1; -} - -pub fn displayWidth(text: []const u8) usize { - var width: usize = 0; - var at: usize = 0; - while (at < text.len) { - const end = modal.nextGrapheme(text, at); - width +|= graphemeDisplayWidth(text[at..end]); - at = end; - } - return width; -} - -/// Source byte at a zero-based display column. Every cell occupied by a tab -/// maps back to that one tab byte. -pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { - var col: usize = 0; - var at: usize = 0; - while (at < text.len) { - const end = modal.nextGrapheme(text, at); - const next = col +| graphemeDisplayWidth(text[at..end]); - if (display_col < next) return at; - col = next; - at = end; - } - return text.len; -} - -/// File cursor columns may live past EOL. Tabs expand before that boundary; -/// every virtual column after it remains one screen cell. -pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize { - const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len)); - return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len); -} - -pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { - const width = displayWidth(line_text); - if (display_col > width) return line_text.len +| (display_col - width); - return byteAtDisplay(line_text, display_col); -} - -pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize { - if (from_raw >= line_text.len) return from_raw +| display_col; - const from = modal.graphemeStart(line_text, from_raw); - return from +| rawAtDisplay(line_text[from..], display_col); -} - -pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { - const from_display = rawDisplayCol(line_text, from_raw); - const to_display = rawDisplayCol(line_text, to_raw); - if (to_display >= from_display) return @intCast(to_display - from_display); - return -@as(i32, @intCast(from_display - to_display)); -} - -pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { - const start = lineDisplayOffset(line_text, from_raw, at_raw); - if (at_raw >= line_text.len) return start; - const at = modal.graphemeStart(line_text, at_raw); - const end = modal.nextGrapheme(line_text, at); - return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1; -} - -pub fn sourceLine(pane: *const Pane, row: i32) []const u8 { - const f = pane.file orelse return ""; - if (row < 0) return ""; - return modal.lineSlice(f.content, @intCast(row)); -} - -pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 { - const line_text = sourceLine(pane, row); - const from: usize = @intCast(@max(0, from_raw)); - const to: usize = @intCast(@max(0, to_raw)); - return lineDisplayOffset(line_text, from, to); -} - -pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { - const line_text = sourceLine(pane, row); - return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw))); -} - -pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { - const line_text = sourceLine(pane, row); - return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)))); -} - -/// Convert between rendered cells and UTF-8 byte columns. Tag rows always -/// need grapheme conversion; file body rows additionally skip PREFIX_W. -pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { - if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); - if (pane.file == null) return rawAtDisplay(line_text, display_col); - const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); - if (display_col <= prefix) return display_col; - return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix); -} - -pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { - if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); - if (pane.file == null) return rawDisplayCol(line_text, byte_col); - const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); - if (byte_col <= prefix) return byte_col; - return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix); -} - -test "display columns map complete Unicode graphemes" { - const text = "é界e\u{301}x"; - try std.testing.expectEqual(@as(usize, 5), displayWidth(text)); - try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0)); - try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1)); - try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2)); - try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3)); - try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4)); - try std.testing.expectEqual(text.len, byteAtDisplay(text, 5)); - try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5)); - try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3)); - try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀")); -} - -test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { - // The arm claims a one-byte printable ASCII grapheme is one cell without asking `gwidth`. That - // is only worth having if the two never disagree, so ask both for every byte the arm can see - - // including \t, \r, the rest of the C0 controls and DEL, which the range test excludes and - // which must therefore still come back from `gwidth` (or, for the tab, from the config). - const ref = struct { - fn width(grapheme: []const u8) usize { - if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; - return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); - } - }.width; - - var one: [1]u8 = undefined; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - one[0] = b; - try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1])); - } - // Multi-byte clusters never reach the arm (len != 1), so they pin that it does not widen its - // claim: a combining sequence and a ZWJ emoji are one and two cells, a CJK glyph is two, and - // an invalid byte is the one cell `gwidth` reports for U+FFFD-shaped input. - for ([_][]const u8{ - "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", - "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", - }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); -} - -test "the ASCII run in fitEnd survives an exhaustive byte sweep" { - // The case list in the test above is hand-picked; this one is not. Every byte 0x00..0x7f is - // placed next to every neighbour that can change the answer - a combining mark, a ZWJ - // sequence, a spacing mark, a variation selector, a wide glyph, and a bad start byte, a - // truncated tail and a bad continuation - and every break column is compared against the - // grapheme walk. An off-by-one column here moves text between wrapped rows, so equality is - // exact, not approximate. - const reference = struct { - fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; - } - }.fitEnd; - - const neighbours = [_][]const u8{ - "", "z", "\u{301}", "\u{200d}\u{1f680}", - "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}", - "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", - }; - var buf: [16]u8 = undefined; - // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary - // as well as before it and inside the multi-byte cluster that follows it. - var prefixed: [18]u8 = undefined; - prefixed[0] = 'a'; - prefixed[1] = 'b'; - var b: u8 = 0; - while (b < 0x80) : (b += 1) { - buf[0] = b; - for (neighbours) |tail| { - @memcpy(buf[1..][0..tail.len], tail); - const pair = buf[0 .. 1 + tail.len]; - @memcpy(prefixed[2..][0..pair.len], pair); - for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| { - var width: usize = 0; - while (width <= text.len + 3) : (width += 1) { - var start: usize = 0; - while (start <= text.len) : (start += 1) { - std.testing.expectEqual( - reference(text, start, width), - fitEnd(text, start, width), - ) catch |e| { - std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width }); - return e; - }; - } - } - } - } - } -} - -fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - // ASCII RUN. This is the loop a wrapped line pays per character, and it asks two function calls - // to learn what arithmetic knows: `modal.nextGrapheme` and `graphemeDisplayWidth` each answer - // ASCII in constant time, but they answer once per character and a 640-column line asks 640 - // times. A printable ASCII byte whose successor is also ASCII is a complete grapheme cluster one - // column wide - the same guard, and the same reason, as `Surface.print` and `modal.nextGrapheme` - // - so consume the run here and leave anything else to the general path below. - while (used < width and end < text.len) { - const b = text[end]; - if (b < 0x20 or b >= 0x7f) break; - if (end + 1 < text.len and text[end + 1] >= 0x80) break; - used += 1; - end += 1; - } - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; -} - -test "the ASCII run in fitEnd cuts where the grapheme walk would" { - // fitEnd decides where a wrapped row BREAKS, so a fast path that is off by one column moves - // text on screen. This pins it to the general walk it replaces rather than to a transcribed - // expectation: same inputs, both routes, every width from 0 past the end of the string. - const reference = struct { - fn fitEnd(text: []const u8, start: usize, width: usize) usize { - var end = start; - var used: usize = 0; - while (end < text.len) { - const next_end = modal.nextGrapheme(text, end); - const next_used = used +| graphemeDisplayWidth(text[end..next_end]); - if (next_used > width) return if (end == start) next_end else end; - used = next_used; - end = next_end; - } - return end; - } - }.fitEnd; - - const cases = [_][]const u8{ - "", - "hello world", - // the fast path must hand over at the first non-ASCII byte, mid-run - "abc\u{00e9}def", - // a wide glyph is two columns, so a width boundary can land inside it - "ab\u{4e16}\u{754c}cd", - // a cluster the fast path must not split - "a\u{0301}bc", - // tabs and controls are excluded from the fast path by the range test - "ab\tcd", - "ab\rcd", - // an ASCII byte followed by a continuation byte is NOT its own cluster - "e\u{0301}x", - "\u{1f1e6}\u{1f1e7}ok", - }; - for (cases) |text| { - var width: usize = 0; - while (width <= text.len + 3) : (width += 1) { - var start: usize = 0; - while (start <= text.len) : (start += 1) { - try std.testing.expectEqual( - reference(text, start, width), - fitEnd(text, start, width), - ); - } - } - } -} - -pub fn lineCount(content: []const u8) usize { - return std.mem.count(u8, content, "\n") + 1; -} - -/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where -/// line i begins and its length is the line count. Without it, every question -/// about lines is a scan from byte 0, and a file pane asks several of them per -/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's -/// bounds, the body's first visible line. On a 300k-line file that was ~35% of -/// the whole frame, and it is what made a single `j` cost 25ms. -/// -/// INVALIDATION — the part that rots if nobody says it out loud. The index is -/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the -/// funnel every content swap in the editor already goes through (typing, undo, -/// redo, a save's normalisation, an output buffer refilling itself). A State -/// built by a struct literal starts with an empty index, and empty reads as -/// "not built yet" — a real index always has at least one entry, because a -/// file always has at least one line. So there is one and only one way to make -/// this wrong: assign `f.content` without going through setContent. Don't. -/// -/// Fails only when the index could not be allocated. nlines and lineStart -/// swallow that and scan the old way, so OOM there is slow rather than wrong; -/// callers that need the whole table say `try` and drop the keystroke, which -/// is what they already did when their own arena ran out. -pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { - if (f.line_starts.len > 0) return f.line_starts; - // Exact allocation: deinitPane frees `line_starts` itself, so the stored - // slice must span the complete allocation rather than spare capacity. - const starts = try gpa.alloc(usize, lineCount(f.content)); - starts[0] = 0; - var i: usize = 1; - var off: usize = 0; - while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| { - off = nl + 1; - starts[i] = off; - i += 1; - } - f.line_starts = starts; - return starts; -} - -/// line count, O(1) once the index is warm -pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { - const idx = lineIndex(gpa, f) catch return lineCount(f.content); - return idx.len; -} - -/// byte offset of line `row`, or content.len past the end — modal -/// .lineStartOffset's contract exactly, without its walk -pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize { - const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row); - return if (row >= idx.len) f.content.len else idx[row]; -} - -pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 { - const index = try lineIndex(pane.gpa, f); - const lines = try arena.alloc([]const u8, index.len); - for (index, 0..) |start, i| { - const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len; - lines[i] = f.content[start..end]; - } - return lines; -} - -/// Use the file's line index only when `text` is its complete live content. -/// Edit-buffer fragments and other temporary text retain modal's scan path. -fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize { - const f = if (pane.file) |*file| file else return null; - if (text.ptr != f.content.ptr or text.len != f.content.len) return null; - return lineIndex(pane.gpa, f) catch null; -} - -pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize { - const index = contentIndex(pane, text) orelse return modal.hxOff(text, cursor); - const row = @min(cursor.row, index.len - 1); - const start = index[row]; - const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; - return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start)); -} - -pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize { - const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row); - return if (row >= index.len) text.len else index[row]; -} - -pub fn textLineCount(pane: *Pane, text: []const u8) usize { - const index = contentIndex(pane, text) orelse return modal.hxLineCount(text); - return index.len; -} - -pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor { - const index = contentIndex(pane, text) orelse return modal.hxPos(text, offset); - const bounded = @min(offset, text.len); - const row = std.sort.upperBound(usize, index, bounded, struct { - fn cmp(key: usize, item: usize) std.math.Order { - return std.math.order(key, item); - } - }.cmp) - 1; - const start = index[row]; - const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; - return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) }; -} - -pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { - const content = try look.readFile(p.gpa, path); - errdefer p.gpa.free(content); - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); - const pane = try p.newDocPane(id); - const total = lineCount(content); - const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; - pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(scroll); - // watches follow pane lifetime: this is the only place a real file is read - // off disk, and deinitPane is the only place one goes away - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Rebuild a dumped file or output buffer. Byte ownership, output identity, -/// cursor projection, and file watching are all properties of this payload; -/// column registration and custom tag restoration remain core invariants. -pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane { - const saved = src.file.?; - const content: []u8 = if (saved.content_b64.len > 0) - try dump.decodeBytes(p.gpa, saved.content_b64) - else - try p.gpa.dupe(u8, saved.content); - errdefer p.gpa.free(content); - const path = try p.gpa.dupe(u8, saved.path); - errdefer p.gpa.free(path); - - const output: ?output_pane.Output = if (output_pane.fromWord(saved.origin)) |origin| blk: { - var value: output_pane.Output = .{ .from = origin }; - try output_pane.setArg(&value, saved.origin_arg); - break :blk value; - } else null; - - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = output, .scroll = src.scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(src.scroll); - pane.cols = @max(1, src.cols); - pane.rows = @max(1, src.rows); - // A restored file is watched exactly like one opened from disk. Its dump - // bytes may differ from disk; the first external write reconciles them and - // leaves the restored version one undo away. Output buffers have no file. - if (output == null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Release the complete file payload while the owning pane is still installed -/// (the slot is needed to identify a disappearing file watch). Common pane -/// overlays and the shared terminal stub remain the core's responsibility. -pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { - if (file.output == null) for (p.panes, 0..) |slot, id| { - if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - }; - p.gpa.free(file.path); - p.gpa.free(file.content); - if (file.line_starts.len > 0) p.gpa.free(file.line_starts); - if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); - for (file.undo[0..file.undo_len]) |snap| p.gpa.free(snap.content); - for (file.redo[0..file.redo_len]) |snap| p.gpa.free(snap.content); -} - -/// TELL A SCRIPT WHAT CHANGED, when one is listening. -/// -/// acme reports edits from the two places that make them — `textinsert` and -/// `textdelete`, which already know their range — so a replacement arrives as -/// a `D` record and then an `I`. pardes has no such pair: every edit lands -/// here as a whole new buffer, so the range is recovered by DIFFING, and -/// `acmefs.noteReplace` owns both the diff and the D-then-I order. -/// -/// The cost is two vectorised scans of the content, and it is paid only while -/// a script holds an `event` file open (`p.fs.listeners`); the editor nobody -/// is scripting does one branch. The pane lookup is a walk of at most -/// MAX_PANES slots comparing the FILE pointer — a file pane's state is stored -/// inline in its pane, so that identifies the pane exactly. -fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { - if (p.fs.listeners == 0) return; - const id = for (p.panes, 0..) |slot, i| { - const pane = slot orelse continue; - if (pane.file) |*state| if (state == f) break i; - } else return; - pardes.acmefs.noteReplace(p, id, false, f.content, new); -} - -/// The ONE content swap. Everything that edits a file pane lands here, which -/// is what lets the line index above have a single invalidation point — and -/// is why one diff HERE is every body edit a script can be told about. -pub fn setContent(p: *Pardes, f: *State, new: []u8) void { - reportEdit(p, f, new); - p.gpa.free(f.content); - f.content = new; - f.revision +%= 1; - if (f.line_starts.len > 0) p.gpa.free(f.line_starts); - f.line_starts = &.{}; - // the highlights go too, and not just because they are stale: their byte - // range is what refreshHighlights tests a scroll against, and a range - // measured on the OLD content would let it skip a re-parse it needs - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = true; -} - -/// undo/redo restores the selection recorded with the snapshot (helix keeps -/// selections in its history transactions), clamped: the content it was taken -/// against may be shorter than the one it is being restored onto. -pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void { - const n = nlines(pane.gpa, f); - const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); -} - -fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void { - if (len.* == slots.len) { - gpa.free(slots[0].content); - std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); - len.* -= 1; - } - slots[len.*] = snap; - len.* += 1; -} - -pub fn pushUndo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return; - const snap: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.undo, &f.undo_len, snap); - for (f.redo[0..f.redo_len]) |item| p.gpa.free(item.content); - f.redo_len = 0; -} - -pub fn undo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.undo_len == 0) return; - const current: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.redo, &f.redo_len, current); - f.undo_len -= 1; - const previous = f.undo[f.undo_len]; - setContent(p, f, previous.content); - restoreSnap(pane, f, previous); -} - -pub fn redo(p: *Pardes, pane: *Pane) void { - const f = if (pane.file) |*file| file else return; - if (f.redo_len == 0) return; - const current: Snapshot = .{ - .content = p.gpa.dupe(u8, f.content) catch return, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }; - pushHistory(p.gpa, &f.undo, &f.undo_len, current); - f.redo_len -= 1; - const next = f.redo[f.redo_len]; - setContent(p, f, next.content); - restoreSnap(pane, f, next); -} - -/// Commit an externally rewritten file onto the same undo history as typed -/// edits. Unsaved work remains one `u` away; there is no third merge state. -pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void { - const pane = p.panes[id] orelse return; - const f = if (pane.file) |*file| file else return; - if (std.mem.eql(u8, f.content, bytes)) return; - const new = p.gpa.dupe(u8, bytes) catch return; - pushUndo(p, pane); - setContent(p, f, new); - // These bytes came from the watched path, so the new on-screen revision - // is already saved. Undoing back to displaced local work bumps revision - // again and makes that restored edit dirty, as it should. - f.saved_revision = f.revision; - // restoreSnap only consumes cursor/selection from this synthetic snapshot. - restoreSnap(pane, f, .{ - .content = undefined, - .cur_row = pane.cur_row, - .cur_col = pane.cur_col, - .vsel = pane.vsel, - }); -} - -/// re-highlight the visible window of any file whose syntax went stale -/// (edit, scroll, load) — visible-range-first so big files stay snappy -pub fn refreshHighlights(p: *Pardes) void { - const tz = tracy.zone(@src(), "refreshHighlights"); - defer tz.end(); - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.file == null) continue; - const f = &pane.file.?; - if (!f.syntax_dirty) continue; - if (!p.settings.colors) { - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - continue; - } - // What the screen needs coloured right now. If the last parse still - // covers it, this scroll is free — and that is the whole point of the - // slack below. Highlights only ever survive while the CONTENT does: - // setContent throws them away, so these byte offsets cannot be stale. - const need_start = lineStart(p.gpa, f, f.scroll); - const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS)); - if (f.highlights.len > 0 and need_start >= f.highlight_start and - need_end <= f.highlight_start + f.highlights.len) - { - f.syntax_dirty = false; - continue; - } - // How much MORE than the screen to parse. An edit or a fresh open has - // no previous parse to widen (setContent throws the highlights away), - // and slack would be pure loss there: every keystroke of typing pays - // this parse and none of it is amortised over anything. A SCROLL that - // outran the covered range is the opposite case — take a screenful - // above and below and the next ~pane.rows rows cost nothing at all. - // Scrolling used to re-parse the visible window on every single row, - // which on a file with 8000-column lines is a third of a megabyte per - // keypress. Three screens once beats one screen forty times. - // - // The slack also means those lines are parsed with real context above - // them, so a construct that opens off-screen now colours correctly — - // a fidelity gain, and one that cannot reach a file shown from the top - // (scroll 0 clamps the window to exactly what it always was). - const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; - // A RESULTS BUFFER IS COLOURED WHOLE, ONCE. Its rows are independent — - // each is parsed in isolation against its own grammar — so a window - // pass buys no fidelity, only amortisation, and pays for it with a - // burst on every scroll that outran the covered range: a fresh parser, - // a fresh query cursor and a tree per row, plus the first compile of - // any grammar the new rows introduce, all inside `render`. Colouring - // the whole buffer when it is FILLED makes the covered-range check - // above true forever after, so scrolling one costs nothing at all. - // Bounded by what fills them: `look.find_max_hits` caps a grep at 512 - // rows, and a rendering is never typed into. - const whole = pane.colorAlgo() == .locations; - const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack); - const end = if (whole) - f.content.len - else - @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); - const new_highlights = (switch (pane.colorAlgo()) { - .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), - .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), - else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), - }) catch { - f.syntax_dirty = false; - continue; - }; - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = new_highlights; - f.highlight_start = if (f.highlights.len > 0) start else 0; - f.syntax_dirty = false; - } -} - -/// The width a wrapped row of THIS pane holds, in cells, or 0 when the pane is -/// not wrapping — the render decision, named once so motion cannot disagree -/// with paint. One column is left for the break marker: a row that filled its -/// last cell would have nowhere to say it continues. A pane taller than the -/// map refuses to wrap rather than record part of itself (see Pane.wrap_line). -pub fn wrapWidth(pane: *const Pane, wrap: bool) usize { - if (!wrap or pane.rows > pane.wrap_line.len) return 0; - return @max(1, @as(usize, pane.cols -| config.PREFIX_W) -| 1); -} - -pub const VisualRow = struct { start: usize, end: usize }; - -/// The visual row of `line` holding byte `col`: `[start, end)`, where `end` is -/// where the next visual row of the same line begins and equals `line.len` on -/// the last one. This is the same walk `fillBody` renders with, so `gj`/`gk` -/// step exactly the breaks a reader sees. `width == 0` (not wrapping) makes -/// the whole line one visual row, which is what collapses visual motion onto -/// textual motion instead of special-casing it upstream. -pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow { - if (width == 0) return .{ .start = 0, .end = line.len }; - var start: usize = 0; - while (true) { - const end = fitEnd(line, start, width); - if (col < end or end >= line.len) return .{ .start = start, .end = end }; - start = end; - } -} - -test "visual rows partition a line at the breaks the body renders" { - const line = "abcdefgh"; - try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3)); - try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3)); - // Past the end (a cursor on the newline) names the LAST row, and a short - // line is one row however narrow the pane is. - try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3)); - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3)); - // A grapheme wider than the row still occupies exactly one row. - try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1)); -} - -/// the body a file pane renders: `pane.rows` SCREEN rows from the scroll -/// offset, each behind its right-aligned line number, then cut by hscroll. -/// -/// With `wrap` on a line too long for the pane takes several rows instead of -/// running off the right edge, and this is where that happens — it is a render -/// property, and the one thing the rest of the editor reads back is the map: -/// which line each row showed and at which byte column it began, recorded into -/// pane.wrap_line/wrap_col as the rows are built. `wrap_n` stays 0 for an -/// unwrapped body, and that is the value the readers treat as "rows are -/// lines", so the off path never consults an array. -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 { - const width = wrapWidth(pane, wrap); - pane.wrap_n = 0; - - // Count the exact rendered bytes first. Unwrapped source lines are not - // bounded by the pane width, so a rows*cols buffer would either truncate - // them or quietly restore a growable builder under another name. - const len = fillBody(null, pane, f, width, false); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, f, width, true); - std.debug.assert(filled == out.len); - return out; -} - -/// Run the file-body row walk. With no destination it is the exact sizing -/// pass; with one it fills that allocation and records the wrapping map. -fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize { - if (record_wrap) pane.wrap_n = 0; - // start ON the first visible line instead of walking the file to it: this - // walk was O(f.scroll) and recolorSyntax below ran the identical one again - var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); - // scrolled past EOF (an edit shortened the file under a stale scroll): the - // old walk left the iterator dry, so drop the one empty line a slice split - // still yields, or the body grows a phantom numbered row - if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); - // the line the NEXT row comes from and the byte column of it that row - // starts at — the two the map records, walked forward by the loop - var abs: i32 = @intCast(f.scroll); - var at: usize = 0; - var cur = flines.next(); - var written: usize = 0; - for (0..pane.rows) |i| { - if (i > 0) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - if (width > 0 and record_wrap) { - pane.wrap_line[i] = abs; - pane.wrap_col[i] = @intCast(at); - pane.wrap_n = @intCast(i + 1); - } - if (cur) |text| { - var lbuf: [16]u8 = undefined; - // unsigned: {d} prints a leading '+' for signed ints - const lineno: usize = @intCast(abs + 1); - // the number belongs to the LINE, so only its first row carries - // one — repeated down a wrapped line it would read as several - // lines, which is exactly what this is not - const prefix = if (at > 0) - " " - else - std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; - if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix); - written += prefix.len; - - // Wrap and horizontal-scroll cuts are always grapheme boundaries. - // Source columns remain byte offsets, while widths are terminal - // cells; keeping the conversion here prevents a view operation - // from manufacturing malformed UTF-8. - const end = if (width == 0) text.len else fitEnd(text, at, width); - const take = end - at; - const cut = if (pane.hscroll > 0 and width == 0) - modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take)) - else - 0; - const shown = text[at + cut .. end]; - if (dst) |out| @memcpy(out[written..][0..shown.len], shown); - written += shown.len; - if (width > 0 and end < text.len) { - at = end; - } else { - abs += 1; - at = 0; - cur = flines.next(); - } - } else abs += 1; - } - return written; -} - -/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not -/// gated on settings.colors; selection/cursor passes still win. The cursor row's -/// number takes the tag style (same row math as renderPane's cursor pass) so -/// the eye finds the current line. -pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { - const s = &p.surface; - const ch = p.chromeTheme(); - const goff = pane.scroll(); - const gcur = term_pane.gridCursor(pane); - const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; - // the cursor's LINE, not its row: wrapped, one line owns a run of rows and - // the number sits on the first of them, so the whole run lights up — the - // gutter is naming the line you are on, and that is still one line - const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); - // the body's first row, the way renderPane derives it (Tagbottom) - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const row_line: i32 = if (pane.wrap_n == 0) - goff + @as(i32, vr) - else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32); - const on_cursor = row_line == cur_line; - var c: u16 = 0; - while (c < config.PREFIX_W and c < tw) : (c += 1) { - const cell = s.at(tx + c, body_y + vr); - cell.default = false; // paints blank gutter rows too - if (on_cursor) { - cell.style.fg = .{ .rgb = ch.tag_fg }; - cell.style.bg = .{ .rgb = ch.tag_bg }; - } else cell.style.fg = .{ .rgb = ch.lineno }; - } - } -} - -const SynStyle = struct { fg: [3]u8, bold: bool }; - -fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { - return switch (sy) { - .none => null, - .keyword => .{ .fg = p.theme().kw, .bold = true }, - .string => .{ .fg = p.theme().str, .bold = false }, - .number => .{ .fg = p.theme().num, .bold = false }, - .comment => .{ .fg = p.theme().comment, .bold = true }, - }; -} - -/// syntax colors: recolor each content cell from its tree-sitter style byte; -/// content starts after the lineno gutter -pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { - if (f.highlights.len == 0) return; - const s = &p.surface; - const tz_recolor = tracy.zone(@src(), "synRecolor"); - defer tz_recolor.end(); - // indexed start, same as bodyText — an empty tail simply paints nothing - var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); - const total = nlines(p.gpa, f); - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - // A colour has to land on the byte it belongs to, so this walk reads - // the same map the body was built from: wrapped, the screen row names - // its own line and the byte column it began at, and it ends where the - // NEXT row of that line begins. Unwrapped the rows ARE the lines in - // order and the split iterator is the cheaper walk. - var base: usize = undefined; - var line: []const u8 = undefined; - var hs: usize = @intCast(@max(0, pane.hscroll)); - var limit: usize = undefined; - if (pane.wrap_n == 0) { - line = flines.next() orelse break; - base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); - limit = line.len; - } else { - if (vr >= pane.wrap_n) break; - const lrow: usize = @intCast(@max(0, pane.wrap_line[vr])); - if (lrow >= total) break; - base = lineStart(p.gpa, f, lrow); - const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len; - line = f.content[base..lend]; - hs = @intCast(pane.wrap_col[vr]); - limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr]) - @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1]))) - else - line.len; - } - hs = modal.graphemeStart(line, @min(hs, line.len)); - var c: usize = 0; - var screen_c: usize = 0; - while (hs + c < limit and config.PREFIX_W + screen_c < tw) { - const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c)); - const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]); - const idx = base + hs + c; - if (idx >= f.highlight_start) { - const hidx = idx - f.highlight_start; - if (hidx < f.highlights.len) { - if (synStyle(p, @enumFromInt(f.highlights[hidx]))) |ss| { - var fill: usize = 0; - while (fill < cells and config.PREFIX_W + screen_c + fill < tw) : (fill += 1) { - const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + screen_c + fill)), body_y + vr); - if (cell.default) continue; - cell.style.fg = .{ .rgb = ss.fg }; - cell.style.bold = ss.bold; - } - } - } - } - screen_c += cells; - c = grapheme_end - hs; - } - } -} - -/// Mark every visible wrapped row which continues onto the next screen row. -/// This is file chrome: it follows syntax recoloring and precedes the shared -/// selection passes, so neither source ink nor the marker can win over a user -/// selection. -pub fn drawWrapMarkers( - p: *Pardes, - pane: *const Pane, - r: pardes.Rect, - tx: u16, - tw: u16, - body_h: u16, - pane_bg: pardes.Color, -) void { - if (tw <= config.PREFIX_W + 1) return; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const marker_fg = p.chromeTheme().lineno; - var row: u16 = 0; - while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { - if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue; - p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ - .fg = .{ .rgb = marker_fg }, - .bg = pane_bg, - }); - } -} - -/// Paint one logical file word through the last frame's wrap map. Unlike a -/// rectangular mouse selection, a path may cross continuation rows without -/// highlighting unrelated cells between its endpoints. -pub fn paintWordSelection( - p: *Pardes, - pane: *Pane, - r: pardes.Rect, - row: i32, - word_lo: i32, - word_hi: i32, - bg: [3]u8, -) void { - const tx = r.x + config.GUTTER; - const tw = r.w - config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var vr: i32 = 0; - while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { - const here = pane.wrapAt(vr); - if (here.line != row) continue; - var hi = word_hi; - const next = pane.wrapAt(vr + 1); - if (next.line == row) hi = @min(hi, next.at); - const lo = @max(word_lo, here.at); - if (hi <= lo) continue; - const c0 = @as(i32, config.PREFIX_W) + displayOffset(pane, row, here.at, lo); - const c1 = @as(i32, config.PREFIX_W) + displayEndOffset(pane, row, here.at, hi - 1); - var col = @max(@as(i32, config.PREFIX_W), c0); - while (col <= c1 and col < @as(i32, tw)) : (col += 1) { - const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); - cell.default = false; - cell.style.bg = .{ .rgb = bg }; - } - } -} diff --git a/src/file_watch.zig b/src/file_watch.zig index dfdc0556..b2b7e380 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -13,8 +13,8 @@ const builtin = @import("builtin"); const libc = std.c; const linux = std.os.linux; const pardes = @import("pardes.zig"); -const look = @import("look.zig"); -const message = @import("message.zig"); +const filesystem = @import("fs.zig"); +const message = pardes.Pardes.Message; pub const Identity = struct { inode: std.Io.File.INode, @@ -51,8 +51,8 @@ pub const Generation = union(enum) { /// rename-over gave the pathname a new inode, without every caller between it /// and a host having to carry the descriptor. /// -/// `generation` is the last snapshot the core accepted, not merely one a host -/// observed; serial prevents a reused pane slot from committing stale data. +/// `generation` is the reconciled disk snapshot; Restore establishes this +/// baseline without replacing its saved buffer. Serial rejects reused slots. pub const Watch = struct { wd: c_int, file_wd: c_int = -1, @@ -319,7 +319,7 @@ fn markFile(kq: c_int, path_z: [:0]const u8) c_int { fn remarkFile(live: *Watch, path: ?[]const u8) void { if (comptime builtin.os.tag != .macos) return; if (live.kq < 0) return; - const watched = path orelse return; + const watched = filesystem.localPath(path orelse return) orelse return; var path_buf: [4096:0]u8 = undefined; if (watched.len >= path_buf.len) return; @memcpy(path_buf[0..watched.len], watched); @@ -380,8 +380,10 @@ fn watchPath( watches[slot] = null; unmark(fd, old, !shared); } - const watched_path = path orelse return; - const dir = std.fs.path.dirname(watched_path) orelse "."; + const declared_path = path orelse return; + const watched_path = filesystem.localPath(declared_path) orelse return; + const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; + const dir = if (stat != null and stat.?.kind == .directory) watched_path else std.fs.path.dirname(watched_path) orelse "."; var dir_buf: [4096:0]u8 = undefined; if (dir.len >= dir_buf.len) return; @memcpy(dir_buf[0..dir.len], dir); @@ -391,7 +393,7 @@ fn watchPath( watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation }; // The file half, which only macos has and only for a path that exists yet. if (comptime builtin.os.tag == .macos) { - if (watches[slot]) |*live| remarkFile(live, watched_path); + if (watches[slot]) |*live| remarkFile(live, declared_path); } } @@ -416,7 +418,6 @@ pub fn watchPane( pub fn reloadPane( core: *pardes.Pardes, io: std.Io, - gpa: std.mem.Allocator, watches: *Table, id: usize, announce: bool, @@ -434,8 +435,8 @@ pub fn reloadPane( } if (pane.file) |file| { - const bytes = look.readFile(gpa, file.path) catch return false; - defer gpa.free(bytes); + const bytes = filesystem.read(core, file.path) catch return false; + defer core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); switch (watched.generation) { .text => |accepted| if (accepted == hash) return false, @@ -485,11 +486,11 @@ pub fn reloadPane( pub fn applyEffect( core: *pardes.Pardes, io: std.Io, - gpa: std.mem.Allocator, fd: c_int, watches: *Table, id: u8, on: bool, + mode: pardes.WatchMode, ) bool { var path: ?[]const u8 = null; var serial: u32 = 0; @@ -505,7 +506,15 @@ pub fn applyEffect( } }; watchPane(fd, watches, id, path, serial, generation); - return on and watches[id] != null and reloadPane(core, io, gpa, watches, id, false); + if (on and mode == .baseline_disk) if (watches[id]) |*watched| { + const pane = core.panes[id] orelse return false; + const file = pane.file orelse return reloadPane(core, io, watches, id, false); + const bytes = filesystem.read(core, file.path) catch return false; + defer core.gpa.free(bytes); + watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }; + return false; + }; + return on and watches[id] != null and reloadPane(core, io, watches, id, false); } /// Reconcile every mark after a coalesced directory wake. @@ -517,7 +526,7 @@ pub fn reloadChanged( ) bool { var retry = false; for (watches[0..pardes.MAX_PANES], 0..) |slot, id| { - if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry; + if (slot != null) retry = reloadPane(core, io, watches, id, true) or retry; } if (watches[theme_slot] != null) retry = reloadTheme(core, gpa, watches, true) or retry; @@ -539,7 +548,7 @@ pub fn applyThemeEffect( watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; const request = core.themeFileRequest(generation) orelse return false; - const bytes = look.readFile(gpa, request.path) catch |err| { + const bytes = filesystem.readFile(gpa, request.path) catch |err| { core.failThemeFile(generation, err); return false; }; @@ -567,7 +576,7 @@ pub fn reloadTheme( if (comptime builtin.os.tag == .macos) { if (watches[theme_slot]) |*entry| remarkFile(entry, request.path); } - const bytes = look.readFile(gpa, request.path) catch |err| { + const bytes = filesystem.readFile(gpa, request.path) catch |err| { core.failThemeFile(watched.serial, err); return false; }; @@ -589,7 +598,8 @@ pub fn reloadTheme( } pub fn identify(io: std.Io, path: []const u8) !Identity { - const stat = try std.Io.Dir.cwd().statFile(io, path, .{}); + const native = filesystem.localPath(path) orelse return error.NonLocalPath; + const stat = try std.Io.Dir.cwd().statFile(io, native, .{}); if (stat.kind != .file) return error.NotFile; return .{ .inode = stat.inode, @@ -599,6 +609,129 @@ pub fn identify(io: std.Io, path: []const u8) !Identity { }; } +test "restored file watches preserve snapshots and accept later disk changes" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const io = std.testing.io; + const gpa = std.testing.allocator; + for ([_]struct { dirty: bool, exists: bool }{ + .{ .dirty = false, .exists = true }, + .{ .dirty = true, .exists = true }, + .{ .dirty = true, .exists = false }, + }) |case| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + if (case.exists) try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "disk baseline\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id: u8 = @intCast(core.freeSlot().?); + const pane = try pardes.panes.File.restore(core, id, .{ + .kind = .file, + .tag = "", + .body = "", + .file = .{ .path = path, .content = "restored snapshot\n", .dirty = case.dirty }, + }); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); + var armed = false; + while (core.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == id and watch.on) { + try std.testing.expectEqual(.baseline_disk, watch.mode); + try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); + armed = true; + }, + else => {}, + }; + try std.testing.expect(armed and watches[id] != null); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(case.dirty, pane.file.?.revision != pane.file.?.saved_revision); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" }); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content); + pardes.panes.File.undo(core, pane); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + _ = reloadChanged(core, io, gpa, &watches); + try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); + } +} + +test "ordinary file watches still reconcile changes between open and watch" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const io = std.testing.io; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "opened\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + while (core.nextEffect()) |_| {} + const id: u8 = @intCast(core.freeSlot().?); + const pane = try pardes.panes.File.open(core, id, path, 0); + try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "changed before watch\n" }); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); + var armed = false; + while (core.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == id and watch.on) { + try std.testing.expectEqual(.reconcile, watch.mode); + try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); + armed = true; + }, + else => {}, + }; + try std.testing.expect(armed and watches[id] != null); + try std.testing.expectEqualStrings("changed before watch\n", pane.file.?.content); + pardes.panes.File.undo(core, pane); + try std.testing.expectEqualStrings("opened\n", pane.file.?.content); +} + +test "explicit OS directory watches refresh prefix-preserving listings" { + if (comptime !supported or !filesystem.platform_has_fs) return; + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "first", .data = "first\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var path_buf: [4102]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/n/os{s}", .{directory}); + const pane_id = core.freeSlot().?; + const pane = try pardes.panes.File.open(core, pane_id, path, 0); + const fd = init(true); + if (fd < 0) return error.NoWatcher; + defer _ = libc.close(fd); + var watches: Table = @splat(null); + defer watchPane(fd, &watches, @intCast(pane_id), null, pane.serial, .{ .text = 0 }); + _ = applyEffect(core, std.testing.io, fd, &watches, @intCast(pane_id), true, .reconcile); + try std.testing.expect(watches[pane_id] != null); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "second", .data = "second\n" }); + _ = reloadChanged(core, std.testing.io, gpa, &watches); + try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "/second\n") != null); + var rows = std.mem.tokenizeScalar(u8, pane.file.?.content, '\n'); + while (rows.next()) |row| { + try std.testing.expect(std.mem.startsWith(u8, row, path)); + const bytes = try filesystem.read(core, row); + defer gpa.free(bytes); + try std.testing.expect(std.mem.endsWith(u8, bytes, "\n")); + } +} + test "file identity changes for in-place and rename-over writes" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); diff --git a/src/fs.zig b/src/fs.zig new file mode 100644 index 00000000..14b6a76a --- /dev/null +++ b/src/fs.zig @@ -0,0 +1,4677 @@ +const std = @import("std"); +const limits = @import("memory.zig").limits; +const libc = std.c; +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const lsp = @import("lsp/lsp.zig"); +const ninep_io = @import("9p_io.zig"); +const panes = @import("panes.zig"); +const mvzr = @import("mvzr"); +const modal = @import("modal.zig"); +const look = @import("look.zig"); + +pub const name_capacity: usize = 255; + +pub const Source = struct { path: []const u8, contents: []const u8 }; +pub const sources: []const Source = if (limits.embedded_sources) + &(source_files ++ @import("effect_sources.zig").files) +else + &.{}; +const source_files = [_]Source{ + .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, + .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, + .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, + .{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") }, + .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, + .{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") }, + .{ .path = "src/look.zig", .contents = @embedFile("look.zig") }, + .{ .path = "src/9p.zig", .contents = @embedFile("9p.zig") }, + .{ .path = "src/9p_io.zig", .contents = @embedFile("9p_io.zig") }, + .{ .path = "src/host_io.zig", .contents = @embedFile("host_io.zig") }, + .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, + .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, + .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, + .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, + .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, +}; + +pub fn sourceBytes(path: []const u8) ?[]const u8 { + for (sources) |entry| if (std.mem.eql(u8, entry.path, path)) return entry.contents; + return null; +} + +pub const WriteError = error{ + PathTooLong, + PermissionDenied, + IsDirectory, + ReadOnlyFilesystem, + NoSpaceLeft, + OpenFailed, + WriteFailed, +}; + +pub fn writeFile(path: []const u8, bytes: []const u8) WriteError!void { + var pathbuf: [4096:0]u8 = undefined; + if (path.len >= pathbuf.len) return error.PathTooLong; + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; + @memcpy(pathbuf[0..path.len], path); + pathbuf[path.len] = 0; + const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return switch (libc.errno(fd)) { + .ACCES, .PERM => error.PermissionDenied, + .ISDIR => error.IsDirectory, + .ROFS => error.ReadOnlyFilesystem, + .NOSPC, .DQUOT => error.NoSpaceLeft, + .NAMETOOLONG => error.PathTooLong, + else => error.OpenFailed, + }; + var off: usize = 0; + var wrote = true; + while (off < bytes.len) { + const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); + if (n < 0 and libc.errno(n) == .INTR) continue; + if (n <= 0) { + wrote = false; + break; + } + off += @intCast(n); + } + const closed = libc.close(fd) == 0; + if (!wrote or !closed) return error.WriteFailed; +} + +extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; + +pub const Mount = struct { name: []const u8, dial: []const u8 }; +pub const max_mounts = 8; +pub const Resolved = struct { path: []const u8, dir: bool = false }; +pub const OsPath = struct { node: u64, path: []const u8 }; +pub const archive_node: u64 = 1 << 61; + +pub fn archiveNode(path: []const u8) ?u64 { + if (path.len == 0) return archive_node; + if (path.len >= 4096) return null; + for (sources, 0..) |source, i| { + if (std.mem.eql(u8, source.path, path) or + (source.path.len > path.len and source.path[path.len] == '/' and std.mem.startsWith(u8, source.path, path))) + return archive_node | (@as(u64, i) << 12) | path.len; + } + return null; +} + +fn archiveInfo(node: u64) ?struct { path: []const u8, contents: []const u8, dir: bool } { + if (node == archive_node) return .{ .path = "", .contents = "", .dir = true }; + const index = (node & ~archive_node) >> 12; + if (index >= sources.len) return null; + const source = sources[@intCast(index)]; + const len: usize = @intCast(node & 4095); + if (len > source.path.len) return null; + return .{ .path = source.path[0..len], .contents = source.contents, .dir = len < source.path.len }; +} + +pub fn stageArchive(p: *pardes.Pardes, out: *std.ArrayList(u8), path: []const u8, skip: *u64) void { + var seen: [sources.len][]const u8 = undefined; + var count: usize = 0; + for (sources, 0..) |source, i| { + if (path.len != 0 and + (source.path.len <= path.len or source.path[path.len] != '/' or !std.mem.startsWith(u8, source.path, path))) continue; + const start = if (path.len == 0) 0 else path.len + 1; + const rest = source.path[start..]; + const len = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; + const name = rest[0..len]; + var duplicate = false; + for (seen[0..count]) |previous| if (std.mem.eql(u8, name, previous)) { + duplicate = true; + break; + }; + if (duplicate) continue; + seen[count] = name; + count += 1; + if (skip.* > 0) { + skip.* -= 1; + continue; + } + const node = archive_node | (@as(u64, i) << 12) | (start + len); + stageDirent(out, p.gpa, node, len < rest.len, name); + } +} + +pub fn archiveHandle(p: *pardes.Pardes, req: Req) Reply { + const info = archiveInfo(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const attr: Reply.Attr = .{ + .node = req.node, + .name = std.fs.path.basename(info.path), + .dir = info.dir, + .size = if (info.dir) 0 else info.contents.len, + .mode = if (info.dir) 0o500 else 0o400, + }; + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = attr }, + .open => return .{ .tag = req.tag, .handle = 1 }, + .release => return .{ .tag = req.tag }, + .lookup => { + if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); + if (std.mem.eql(u8, req.data, "..")) { + const parent = std.fs.path.dirname(info.path) orelse ""; + if (parent.len == 0) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); + const node = archiveNode(parent) orelse return Reply.fail(req.tag, E.NOENT); + return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + } + if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + var buf: [4096]u8 = undefined; + const path = if (info.path.len == 0) req.data else std.fmt.bufPrint(&buf, "{s}/{s}", .{ info.path, req.data }) catch return Reply.fail(req.tag, E.NOENT); + const node = archiveNode(path) orelse return Reply.fail(req.tag, E.NOENT); + return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + }, + .readdir => { + if (!info.dir) return Reply.fail(req.tag, E.NOTDIR); + const out = p.fs.stage(p.gpa); + var skip = req.off; + stageArchive(p, out, info.path, &skip); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + .read => { + if (info.dir) return Reply.fail(req.tag, E.PERM); + const off: usize = @intCast(@min(req.off, info.contents.len)); + const bytes = info.contents[off..][0..@min(req.size, info.contents.len - off)]; + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +fn osPath(p: *pardes.Pardes, node: u64) ?[]const u8 { + if (node == os_root) return "/"; + for (p.fs.os_paths.items) |entry| if (entry.node == node) return entry.path; + return null; +} + +fn osNode(p: *pardes.Pardes, path: []const u8) !u64 { + if (std.mem.eql(u8, path, "/")) return os_root; + const node = os_node | (std.hash.Wyhash.hash(0, path) & (os_node - 1)); + for (p.fs.os_paths.items) |entry| { + if (std.mem.eql(u8, entry.path, path)) return entry.node; + if (entry.node == node) return error.NodeCollision; + } + if (p.fs.os_paths.items.len == 4096) return error.TooManyFiles; + const saved = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(saved); + try p.fs.os_paths.append(p.gpa, .{ .node = node, .path = saved }); + return node; +} + +pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { + if (comptime !platform_has_fs) return Reply.fail(req.tag, E.NOENT); + const path = osPath(p, req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .release) return .{ .tag = req.tag }; + const io = std.Io.Threaded.global_single_threaded.io(); + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); + const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o500 else 0o600 }; + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = attr }, + .open => { + if (stat.kind != .file and stat.kind != .directory) return Reply.fail(req.tag, E.PERM); + return .{ .tag = req.tag, .handle = 1 }; + }, + .lookup => { + if (stat.kind != .directory) return Reply.fail(req.tag, E.NOTDIR); + if (req.node == os_root and std.mem.eql(u8, req.data, "..")) + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = namespace_root }); + if (req.data.len == 0 or std.mem.indexOfAny(u8, req.data, "/\x00") != null) return Reply.fail(req.tag, E.NOENT); + var buf: [4096]u8 = undefined; + const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), req.data }) catch return Reply.fail(req.tag, E.NOENT); + var normalized_buf: [4096]u8 = undefined; + const normalized = resolveOs(joined, &normalized_buf) orelse return Reply.fail(req.tag, E.NOENT); + const node = osNode(p, normalized.path) catch return Reply.fail(req.tag, E.NFILE); + return osHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + }, + .readdir => { + var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return Reply.fail(req.tag, E.NOTDIR); + defer dir.close(io); + var it = dir.iterate(); + const out = p.fs.stage(p.gpa); + var skip = req.off; + while (it.next(io) catch return Reply.fail(req.tag, E.IO)) |entry| { + var buf: [4096]u8 = undefined; + const joined = std.fmt.bufPrint(&buf, "{s}/{s}", .{ std.mem.trimEnd(u8, path, "/"), entry.name }) catch continue; + var normalized_buf: [4096]u8 = undefined; + const normalized = resolveOs(joined, &normalized_buf) orelse continue; + const child = dir.statFile(io, entry.name, .{}) catch continue; + if (skip > 0) { + skip -= 1; + continue; + } + const node = if (std.mem.eql(u8, normalized.path, "/")) os_root else os_node | (std.hash.Wyhash.hash(0, normalized.path) & (os_node - 1)); + stageDirent(out, p.gpa, node, child.kind == .directory, entry.name); + if (out.items.len >= @max(req.size, 512)) break; + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + .read, .write, .setattr => { + if (stat.kind != .file) return Reply.fail(req.tag, E.PERM); + var z: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return Reply.fail(req.tag, E.NOENT); + const fd = libc.open(path_z, .{ .ACCMODE = if (req.op == .read) .RDONLY else .WRONLY, .NONBLOCK = true, .CLOEXEC = true }); + if (fd < 0) return Reply.fail(req.tag, E.PERM); + defer _ = libc.close(fd); + if (req.op == .setattr) { + if (!req.truncate or req.off != 0 or libc.ftruncate(fd, 0) != 0) return Reply.fail(req.tag, E.INVAL); + var truncated = attr; + truncated.size = 0; + return .{ .tag = req.tag, .attr = truncated }; + } + if (req.off > std.math.maxInt(i64)) return Reply.fail(req.tag, E.INVAL); + if (libc.lseek(fd, @intCast(req.off), libc.SEEK.SET) < 0) return Reply.fail(req.tag, E.IO); + if (req.op == .write) { + const written = libc.write(fd, req.data.ptr, req.data.len); + if (written < 0) return Reply.fail(req.tag, E.IO); + return .{ .tag = req.tag, .written = @intCast(written) }; + } + const out = p.fs.stage(p.gpa); + out.resize(p.gpa, @min(req.size, 65536)) catch return Reply.fail(req.tag, E.NOMEM); + const got = libc.read(fd, out.items.ptr, out.items.len); + if (got < 0) return Reply.fail(req.tag, E.IO); + out.shrinkRetainingCapacity(@intCast(got)); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(got) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +pub fn validMountName(name: []const u8) bool { + if (name.len == 0 or name.len > name_capacity or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + if (std.mem.eql(u8, name, "os") or std.mem.eql(u8, name, "self")) return false; + for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return false; + return true; +} + +test "mounts own their names and dials and reject duplicate or reserved names" { + const gpa = std.testing.allocator; + var ns: Namespace = .{}; + defer ns.deinit(gpa); + var name = "peer".*; + var dial = "/tmp/peer.sock".*; + try ns.mount(gpa, &name, &dial); + @memset(&name, 'x'); + @memset(&dial, 'x'); + try std.testing.expectEqualStrings("peer", ns.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/peer.sock", ns.mounts.items[0].dial); + try std.testing.expectError(error.AlreadyMounted, ns.mount(gpa, "peer", "/tmp/other.sock")); + for ([_][]const u8{ "", ".", "..", "os", "self", "a/b", "with space" }) |bad| + try std.testing.expectError(error.BadMountName, ns.mount(gpa, bad, "/tmp/peer.sock")); + for ([_][]const u8{ "", "a\x00b", "tcp!127.0.0.1!0", "tcp!localhost!564" }) |bad| + try std.testing.expectError(error.BadDial, ns.mount(gpa, "valid", bad)); + for (1..max_mounts) |i| { + var buf: [16]u8 = undefined; + try ns.mount(gpa, try std.fmt.bufPrint(&buf, "peer{d}", .{i}), "/tmp/peer.sock"); + } + try std.testing.expectEqual(max_mounts, ns.mounts.items.len); + try std.testing.expectError(error.TooManyMounts, ns.mount(gpa, "full", "/tmp/peer.sock")); +} + +test "mount allocation failures preserve prior mounts and release partial copies" { + const Check = struct { + fn run(gpa: std.mem.Allocator) !void { + var ns: Namespace = .{}; + defer ns.deinit(gpa); + try ns.mount(gpa, "first", "/tmp/first.sock"); + ns.mount(gpa, "second", "/tmp/second.sock") catch |err| { + try std.testing.expectEqual(@as(usize, 1), ns.mounts.items.len); + try std.testing.expectEqualStrings("first", ns.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/first.sock", ns.mounts.items[0].dial); + return err; + }; + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); +} + +test "unmount refuses pane paths inherited directories and queued save targets" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + try p.fs.mount(gpa, "peer", "/tmp/peer.sock"); + const pane = try p.setTestFile("unsaved\n"); + for ([_][]const u8{ "/n/peer", "/n/peer/file", "/n/peer/dir/file" }) |path| { + gpa.free(pane.file.?.path); + pane.file.?.path = try gpa.dupe(u8, path); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + } + gpa.free(pane.file.?.path); + pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); + const shell = try p.newShell(1, "/n/peer/dir"); + p.setCwd(1, "/n/peer/dir"); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + pane.cwd = .{ .inherited = shell }; + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + p.setCwd(1, "/"); + while (p.nextEffect()) |_| {} + p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); + try std.testing.expectError(error.MountInUse, unmount(p, "peer")); + while (p.nextEffect()) |_| {} + try unmount(p, "peer"); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + try std.testing.expectError(error.NotMounted, unmount(p, "peer")); + try p.fs.mount(gpa, "peer", "/tmp/other.sock"); + try std.testing.expectEqualStrings("/tmp/other.sock", p.fs.mounts.items[0].dial); +} + +test "virtual writes enforce permissions even when contents are empty" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + for ([_][]const u8{ "", "bytes" }) |bytes| { + for ([_][]const u8{ "/virtual/index", "/virtual/screen", "/virtual/src/fs.zig", "/n/self/src/fs.zig" }) |path| + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, path, bytes)); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual/src", bytes)); + try std.testing.expectError(error.FileNotFound, write(p, "/virtual/missing", bytes)); + } + for (p.fs.snapshots) |snapshot| try std.testing.expect(snapshot.node == 0); +} + +test "direct self reads and writes use the same dot paths as Look" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("old contents\n"); + var path_buf: [256]u8 = undefined; + for ([_][]const u8{ "/virtual", "/n/self" }) |root| { + const path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/{d}/../{d}/body/./", .{ root, pane.serial, pane.serial }); + try write(p, path, "replacement\n"); + try std.testing.expectEqualStrings("replacement\n", pane.file.?.content); + const bytes = try read(p, path); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(pane.file.?.content, bytes); + var resolved: [4096]u8 = undefined; + const canonical = resolve(p, path, "/", &resolved).?; + try std.testing.expectEqual(resolveSelf(p, canonical.path[9..]), resolveSelf(p, path[root.len..])); + try std.testing.expectEqual(@as(?u64, @intFromEnum(SelfFile.root)), resolveSelf(p, "./pane/../../")); + const index_path = try std.fmt.bufPrint(&path_buf, "{s}/./pane/../index", .{root}); + try std.testing.expectError(error.ReadOnlyFilesystem, write(p, index_path, "")); + const index = try read(p, index_path); + defer gpa.free(index); + try std.testing.expect(std.mem.indexOf(u8, index, "/test.txt") != null); + } + var overlong: [4110]u8 = @splat('x'); + @memcpy(overlong[0..9], "/virtual/"); + var resolved: [4096]u8 = undefined; + try std.testing.expect(resolve(p, &overlong, "/", &resolved) == null); + try std.testing.expect(resolveSelf(p, overlong[9..]) == null); + try std.testing.expectError(error.FileNotFound, write(p, &overlong, "")); +} + +test "self files share the regular file limit without preallocating it" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const contents = try gpa.alloc(u8, limits.max_stream_bytes + 17); + defer gpa.free(contents); + @memset(contents, 'x'); + const pane = try p.setTestFile(contents); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); + const bytes = try read(p, path); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, contents, bytes); +} + +test "bounded reads accept exact OS file lengths and empty files" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var contents: [8209]u8 = @splat('x'); + contents[contents.len - 1] = '\n'; + for ([_][]const u8{ &contents, "" }) |expected| { + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "limited.txt", .data = expected }); + var path_buf: [4096]u8 = undefined; + const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "limited.txt", &path_buf)]; + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); + for ([_][]const u8{ native, explicit }) |path| { + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, expected, bytes); + if (expected.len > 0) { + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + } + } + try tmp.dir.createDir(std.testing.io, "empty", .default_dir); + var path_buf: [4096]u8 = undefined; + const native = path_buf[0..try tmp.dir.realPathFile(std.testing.io, "empty", &path_buf)]; + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{native}); + const empty = try readLimit(p, explicit, 0); + defer gpa.free(empty); + try std.testing.expectEqual(@as(usize, 0), empty.len); + try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); +} + +test "bounded reads apply the same limits to self bodies and embedded sources" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; + try p.fs.mount(gpa, "own", p.fs.socket_path); + var contents: [8209]u8 = @splat('x'); + contents[contents.len - 1] = '\n'; + for ([_][]const u8{ &contents, "" }) |expected| { + const pane = try p.setTestFile(expected); + for ([_][]const u8{ "/virtual", "/n/self", "/n/own/self" }) |prefix| { + if (!platform_has_fs and std.mem.eql(u8, prefix, "/n/own/self")) continue; + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/pane/{d}/body", .{ prefix, pane.serial }); + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualSlices(u8, expected, bytes); + if (expected.len > 0) { + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + try std.testing.expectEqualSlices(u8, expected, pane.file.?.content); + } + } + if (limits.embedded_sources) { + const source = findEmbeddedSource("src/look.zig", false).?; + for ([_][]const u8{ "/virtual/src/look.zig", "/n/self/src/look.zig", "/n/own/self/src/look.zig" }) |path| { + if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own/")) continue; + const bytes = try readLimit(p, path, source.contents.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(source.contents, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, source.contents.len - 1)); + } + const bytes = try readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(source.contents, bytes); + try std.testing.expectError(error.FileTooLarge, readFileLimit(gpa, "/virtual/src/look.zig", source.contents.len - 1)); + } +} + +test "bounded reads count rendered directory paths and unknown self lengths" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/pardes-limited-in-process.sock"; + try p.fs.mount(gpa, "own", p.fs.socket_path); + for ([_][]const u8{ "/n", "/virtual", "/n/self", "/n/own", "/n/own/self", "/virtual/listeners" }) |path| { + if (!platform_has_fs and std.mem.startsWith(u8, path, "/n/own")) continue; + const expected = try read(p, path); + defer gpa.free(expected); + try std.testing.expect(expected.len > 0); + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } + try std.testing.expectEqual(@as(usize, 0), p.fs.os_paths.items.len); +} + +test "bounded reads release terminal snapshots after success and size refusal" { + if (!pardes.terminal_panes) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{serialOf(p)}); + const empty = try readLimit(p, path, 0); + defer gpa.free(empty); + try std.testing.expectEqual(@as(usize, 0), empty.len); + p.update(.{ .output = .{ .pane = 0, .bytes = "limited terminal output" } }); + const expected = "limited terminal output"; + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + for (p.fs.snapshots) |snapshot| { + try std.testing.expectEqual(@as(u64, 0), snapshot.node); + try std.testing.expect(snapshot.bytes == null); + } +} + +test "bounded reads probe size-unknown proc files at the exact limit" { + if (!platform_has_fs or @import("builtin").os.tag != .linux) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const expected = read(p, "/proc/version") catch |err| return switch (err) { + error.FileNotFound, error.PermissionDenied => error.SkipZigTest, + else => err, + }; + defer gpa.free(expected); + try std.testing.expect(expected.len > 0); + var storage: [256 * 1024]u8 = undefined; + var fixed = std.heap.FixedBufferAllocator.init(&storage); + const bounded = try readFileLimit(fixed.allocator(), "/proc/version", limits.max_stream_bytes); + try std.testing.expectEqualStrings(expected, bounded); + fixed.allocator().free(bounded); + for ([_][]const u8{ "/proc/version", "/n/os/proc/version" }) |path| { + const bytes = try readLimit(p, path, expected.len); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, expected.len - 1)); + try std.testing.expectError(error.FileTooLarge, readLimit(p, path, 0)); + } +} + +test "self file reads release partial allocations when memory runs out" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const contents = try gpa.alloc(u8, 65537); + defer gpa.free(contents); + @memset(contents, 'x'); + const pane = try p.setTestFile(contents); + var path_buf: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{pane.serial}); + const Read = struct { + fn run(allocator: std.mem.Allocator, core: *Pardes, name: []const u8) !void { + const original = core.gpa; + core.gpa = allocator; + defer core.gpa = original; + const bytes = read(core, name) catch |err| return switch (err) { + error.WriteFailed => error.OutOfMemory, + else => err, + }; + defer allocator.free(bytes); + try std.testing.expectEqual(@as(usize, 65537), bytes.len); + } + }; + try std.testing.checkAllAllocationFailures(gpa, Read.run, .{ p, path }); + try std.testing.expectEqualSlices(u8, contents, pane.file.?.content); +} + +test "oversized OS files fail before allocating their contents" { + if (!platform_has_fs) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const file = try tmp.dir.createFile(std.testing.io, "oversized", .{}); + defer file.close(std.testing.io); + try file.setLength(std.testing.io, limits.max_file_bytes + 1); + var path_buf: [4096]u8 = undefined; + const path_len = try tmp.dir.realPathFile(std.testing.io, "oversized", &path_buf); + var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{ .fail_index = 0 }); + try std.testing.expectError(error.FileTooLarge, readFile(failing.allocator(), path_buf[0..path_len])); + try std.testing.expectEqual(@as(usize, 0), failing.allocations); +} + +test "filesystem roots list their namespaces without dialing remote mounts" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + try p.fs.mount(gpa, "peer", "unavailable-session"); + for ([_][]const u8{ "/n", "/n/", "/n///" }) |path| { + const contents = try read(p, path); + defer gpa.free(contents); + try std.testing.expectEqualStrings("/n/os/\n/n/self/\n/n/peer/\n", contents); + var buf: [4096]u8 = undefined; + try std.testing.expectEqualStrings("/n", resolve(p, path, "/", &buf).?.path); + try std.testing.expectError(error.IsDirectory, write(p, path, "")); + } + const bare = try read(p, "/virtual"); + defer gpa.free(bare); + const slashed = try read(p, "/virtual/"); + defer gpa.free(slashed); + try std.testing.expectEqualStrings(bare, slashed); + try std.testing.expect(std.mem.indexOf(u8, bare, "/virtual/index\n") != null); + try std.testing.expectError(error.IsDirectory, write(p, "/virtual", "")); +} + +test "virtual body writes can read their input from the same pane" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("same pane contents\n"); + const event_node = Node.of(pane.serial, .event); + _ = handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = event_node }); + var path_buffer: [64]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial}); + try write(p, path, pane.file.?.content); + try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 2), pane.file.?.history.undo_len); + const events = &p.fs.panes[p.active].events; + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED")); + events.pop(); + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "EI")); + events.pop(); + try std.testing.expect(events.empty()); + try write(p, path, pane.file.?.content[5..]); + try std.testing.expectEqualStrings("pane contents\n", pane.file.?.content); +} + +test "same-core mount directories preserve their mount prefix across reads" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const socket_path = "/tmp/pardes-in-process-mount.sock"; + p.fs.socket_path = socket_path; + try p.fs.mount(gpa, "own", socket_path); + const roots = try read(p, "/n/own"); + defer gpa.free(roots); + try std.testing.expectEqualStrings("/n/own/os/\n/n/own/self/\n", roots); + const self = try read(p, "/n/own/self"); + defer gpa.free(self); + try std.testing.expect(std.mem.indexOf(u8, self, "/n/own/self/index\n") != null); + try std.testing.expectError(error.NotADirectory, read(p, "/n/own/self/index/..")); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + for (0..520) |i| { + var name: [32]u8 = undefined; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = try std.fmt.bufPrint(&name, "entry-{d:0>4}.txt", .{i}), .data = "child\n" }); + } + var directory_buffer: [4096]u8 = undefined; + const directory = directory_buffer[0..try tmp.dir.realPath(std.testing.io, &directory_buffer)]; + const held_node = try osNode(p, directory); + const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{directory}); + defer gpa.free(path); + const listing = try read(p, path); + defer gpa.free(listing); + var entries = std.mem.tokenizeScalar(u8, listing, '\n'); + var count: usize = 0; + while (entries.next()) |entry| { + try std.testing.expect(std.mem.startsWith(u8, entry, path)); + const child = try read(p, entry); + defer gpa.free(child); + try std.testing.expectEqualStrings("child\n", child); + try std.testing.expectEqual(@as(usize, 1), p.fs.os_paths.items.len); + count += 1; + } + try std.testing.expectEqual(@as(usize, 520), count); + try std.testing.expectEqual(held_node, p.fs.os_paths.items[0].node); + try std.testing.expectEqualStrings(directory, p.fs.os_paths.items[0].path); +} + +pub fn isVirtual(path: []const u8) bool { + return std.mem.eql(u8, path, "/virtual") or std.mem.startsWith(u8, path, "/virtual/") or + std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); +} + +pub fn localPath(path: []const u8) ?[]const u8 { + if (std.mem.eql(u8, path, "/n/os")) return "/"; + if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; + if (isVirtual(path)) return null; + return path; +} + +test "explicit OS paths retain their namespace until an OS boundary" { + for ([_]struct { declared: []const u8, native: ?[]const u8 }{ + .{ .declared = "/n/os", .native = "/" }, + .{ .declared = "/n/os/project/file", .native = "/project/file" }, + .{ .declared = "/n/os/virtual/index", .native = "/virtual/index" }, + .{ .declared = "/n/os/n/self/index", .native = "/n/self/index" }, + .{ .declared = "/n/self/index", .native = null }, + .{ .declared = "/n/peer/os/project/file", .native = null }, + .{ .declared = "/virtual/index", .native = null }, + .{ .declared = "/project/file", .native = "/project/file" }, + }) |case| { + const native = localPath(case.declared); + if (case.native) |expected| try std.testing.expectEqualStrings(expected, native.?) else try std.testing.expect(native == null); + } +} + +pub fn resolve(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, out: *[4096]u8) ?Resolved { + if (word.len == 0 or std.mem.indexOfScalar(u8, word, 0) != null) return null; + var joined_buf: [4096]u8 = undefined; + const joined = if (word[0] == '/') word else std.fmt.bufPrint(&joined_buf, "{s}/{s}", .{ cwd, word }) catch return null; + if (std.mem.eql(u8, std.mem.trimEnd(u8, joined, "/"), "/n")) return .{ .path = "/n" }; + + if (std.mem.startsWith(u8, joined, "/n/")) { + const explicit = joined[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) { + var native_buf: [4096]u8 = undefined; + const native = resolveOs(path, &native_buf) orelse return null; + return .{ .path = std.fmt.bufPrint(out, "/n/os{s}", .{native.path}) catch return null }; + } + if (std.mem.eql(u8, name, "self")) return resolveVirtual(p, path, out); + const core = p orelse return null; + for (core.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, mount.name, name)) continue; + const normalized = normalizeVirtualPath(path, out) orelse return null; + var remote_path: [4096]u8 = undefined; + const saved = std.fmt.bufPrint(&remote_path, "/n/{s}/{s}", .{ name, normalized }) catch return null; + @memcpy(out[0..saved.len], saved); + return .{ .path = out[0..saved.len] }; + } + return null; + } + if (std.mem.eql(u8, joined, "/virtual")) return resolveVirtual(p, "/", out); + if (std.mem.startsWith(u8, joined, "/virtual/")) return resolveVirtual(p, joined[8..], out); + if (resolveOs(joined, out)) |found| return found; + if (resolveVirtual(p, joined, out)) |found| return found; + if (resolveVirtual(p, word, out)) |found| return found; + if (resolveEmbedded(word, cwd, out)) |source| { + const path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null; + return .{ .path = path }; + } + return null; +} + +pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved { + if (comptime !platform_has_fs) return null; + var z: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&z, "{s}", .{path}, 0) catch return null; + const resolved = realpath(path_z, out) orelse return null; + return .{ .path = std.mem.span(resolved), .dir = isDir(resolved) }; +} + +fn resolveVirtual(p: ?*pardes.Pardes, path: []const u8, out: *[4096]u8) ?Resolved { + var normalized_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return null; + if (p) |core| if (resolveSelf(core, normalized) != null) + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; + if (findEmbeddedSource(normalized, false)) |source| + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{source.path}) catch return null }; + if (archiveNode(normalized) != null) + return .{ .path = std.fmt.bufPrint(out, "/virtual/{s}", .{normalized}) catch return null }; + return null; +} + +pub fn read(p: *pardes.Pardes, path: []const u8) ![]u8 { + return readLimit(p, path, limits.max_file_bytes); +} + +pub fn readLimit(p: *pardes.Pardes, path: []const u8, max_bytes: usize) ![]u8 { + const limit = @min(max_bytes, limits.max_file_bytes); + if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + if (limit < "/n/os/\n/n/self/\n".len) return error.FileTooLarge; + try out.writer.writeAll("/n/os/\n/n/self/\n"); + for (p.fs.mounts.items) |mount| { + if (mount.name.len + 5 > limit - out.written().len) return error.FileTooLarge; + try out.writer.print("/n/{s}/\n", .{mount.name}); + } + return out.toOwnedSlice(); + } + if (std.mem.eql(u8, path, "/virtual")) return readNode(p, @intFromEnum(SelfFile.root), path, limit); + if (std.mem.startsWith(u8, path, "/n/")) { + const explicit = path[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) { + var native_buf: [4096]u8 = undefined; + const native = resolveOs(remote_path, &native_buf) orelse return readFileLimit(p.gpa, path, limit); + if (!native.dir) return readFileLimit(p.gpa, path, limit); + const saved_paths = p.fs.os_paths.items.len; + defer { + for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); + p.fs.os_paths.shrinkRetainingCapacity(saved_paths); + } + const node = try osNode(p, native.path); + return readNode(p, node, path, limit); + } + if (std.mem.eql(u8, name, "self")) { + const node = resolveSelf(p, remote_path) orelse return error.FileNotFound; + return readNode(p, node, path, limit); + } + for (p.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, name, mount.name)) continue; + if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { + const saved_paths = p.fs.os_paths.items.len; + defer { + for (p.fs.os_paths.items[saved_paths..]) |temporary| p.gpa.free(temporary.path); + p.fs.os_paths.shrinkRetainingCapacity(saved_paths); + } + var node = namespace_root; + var directory = true; + var parts = std.mem.tokenizeScalar(u8, remote_path, '/'); + while (parts.next()) |part| { + if (!directory) return error.NotADirectory; + if (std.mem.eql(u8, part, ".")) continue; + const reply = handle(p, .{ .tag = 0, .op = .lookup, .node = node, .data = part }); + if (reply.status != .ok) return error.FileNotFound; + node = reply.attr.node; + directory = reply.attr.dir; + } + return readNode(p, node, path, limit); + } + return ninep_io.Client.readLimit(p.gpa, mount.dial, remote_path, path, limit); + } + return error.FileNotFound; + } + if (std.mem.startsWith(u8, path, "/virtual/")) { + const name = path[9..]; + if (resolveSelf(p, name)) |node| return readNode(p, node, path, limit); + } + return readFileLimit(p.gpa, path, limit); +} + +fn readNode(p: *pardes.Pardes, initial_node: u64, path: []const u8, limit: usize) ![]u8 { + var node = initial_node; + const attr = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + if (attr.status != .ok) return error.FileNotFound; + if (attr.attr.dir) { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + var index: u64 = 0; + const max_bytes = @min(limit, limits.max_stream_bytes); + const prefix = std.mem.trimEnd(u8, path, "/"); + while (true) { + const reply = handle(p, .{ .tag = 0, .op = .readdir, .node = node, .off = index, .size = 8192 }); + if (reply.status != .ok) return error.ReadFailed; + const entries = p.fsPayload(reply); + if (entries.len == 0) return out.toOwnedSlice(); + var off: usize = 0; + while (off + 10 <= entries.len) { + const len: usize = entries[off + 9]; + if (off + 10 + len > entries.len) return error.ReadFailed; + const row_len = prefix.len + len + 2 + @as(usize, @intFromBool(entries[off + 8] != 0)); + if (row_len > max_bytes - out.written().len) return error.FileTooLarge; + try out.writer.print("{s}/{s}", .{ prefix, entries[off + 10 ..][0..len] }); + if (entries[off + 8] != 0) try out.writer.writeByte('/'); + try out.writer.writeByte('\n'); + off += 10 + len; + index += 1; + } + if (off != entries.len) return error.ReadFailed; + } + } + if (attr.attr.size > limit) return error.FileTooLarge; + const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + if (opened.status != .ok) return error.OpenFailed; + if (opened.attr.node != 0) node = opened.attr.node; + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + while (true) { + const want: u32 = @intCast(@min(8192, limit + 1 - out.written().len)); + const reply = handle(p, .{ .tag = 0, .op = .read, .node = node, .handle = opened.handle, .off = out.written().len, .size = want }); + if (reply.status == .again) return error.NotAFile; + if (reply.status != .ok) return error.ReadFailed; + const bytes = p.fsPayload(reply); + if (bytes.len == 0) return out.toOwnedSlice(); + if (bytes.len > limit - out.written().len) return error.FileTooLarge; + try out.writer.writeAll(bytes); + } +} + +pub fn write(p: *pardes.Pardes, path: []const u8, bytes: []const u8) !void { + if (std.mem.eql(u8, std.mem.trimEnd(u8, path, "/"), "/n")) return error.IsDirectory; + var self_path: ?[]const u8 = null; + if (std.mem.eql(u8, path, "/virtual")) self_path = ""; + if (std.mem.startsWith(u8, path, "/virtual/")) self_path = path[9..]; + if (std.mem.startsWith(u8, path, "/n/")) { + const explicit = path[3..]; + const cut = std.mem.indexOfScalar(u8, explicit, '/') orelse explicit.len; + const name = explicit[0..cut]; + const remote_path = if (cut < explicit.len) explicit[cut..] else "/"; + if (std.mem.eql(u8, name, "os")) return writeFile(remote_path, bytes); + if (std.mem.eql(u8, name, "self")) { + self_path = remote_path; + } else { + for (p.fs.mounts.items) |mount| { + if (!std.mem.eql(u8, name, mount.name)) continue; + if (ninep_io.Client.sameSession(mount.dial, p.fs.socket_path, p.fs.tcp_address, p.fs.quic_address)) { + var buf: [4096]u8 = undefined; + const local = try std.fmt.bufPrint(&buf, "/n{s}", .{remote_path}); + return write(p, local, bytes); + } + return ninep_io.Client.write(p.gpa, mount.dial, remote_path, bytes); + } + return error.FileNotFound; + } + } + if (self_path) |name| { + var node = resolveSelf(p, name) orelse return error.FileNotFound; + const attributes = handle(p, .{ .tag = 0, .op = .getattr, .node = node }); + if (attributes.status != .ok) return error.FileNotFound; + if (attributes.attr.dir) return error.IsDirectory; + if (attributes.attr.mode & 0o200 == 0) return error.ReadOnlyFilesystem; + const opened = handle(p, .{ .tag = 0, .op = .open, .node = node }); + if (opened.status != .ok) return error.OpenFailed; + if (opened.attr.node != 0) node = opened.attr.node; + defer _ = handle(p, .{ .tag = 0, .op = .release, .node = node, .handle = opened.handle }); + var preserved: ?[]u8 = null; + defer if (preserved) |copy| p.gpa.free(copy); + const target = Node.target(node); + if (target != null and target.? == .pane and target.?.pane.file == .body) { + preserved = try p.gpa.dupe(u8, bytes); + const trunc = handle(p, .{ .tag = 0, .op = .setattr, .node = node, .truncate = true }); + if (trunc.status != .ok) return error.WriteFailed; + } + const contents = preserved orelse bytes; + var off: usize = 0; + while (off < contents.len) { + const reply = handle(p, .{ .tag = 0, .op = .write, .node = node, .off = off, .data = contents[off..] }); + if (reply.status != .ok or reply.written == 0) return error.WriteFailed; + off += reply.written; + } + return; + } + return writeFile(path, bytes); +} + +fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?Source { + var wordbuf: [4096]u8 = undefined; + const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; + if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); + + var joined: [4096]u8 = undefined; + if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| + if (normalizeVirtualPath(candidate, scratch)) |normalized| + if (findEmbeddedSource(normalized, true)) |source| return source; + return findEmbeddedSource(normalized_word, false); +} + +fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, path, 0) != null) return null; + var len: usize = 0; + var parts = std.mem.tokenizeAny(u8, path, "/\\"); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, ".")) continue; + if (std.mem.eql(u8, part, "..")) { + while (len > 0 and out[len - 1] != '/') len -= 1; + if (len > 0) len -= 1; + continue; + } + const extra = part.len + @intFromBool(len != 0); + if (len + extra > out.len) return null; + if (len != 0) { + out[len] = '/'; + len += 1; + } + @memcpy(out[len..][0..part.len], part); + len += part.len; + } + return out[0..len]; +} + +fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?Source { + for (sources) |source| + if (std.mem.eql(u8, source.path, path)) return source; + if (!allow_root_suffix) return null; + for (sources) |source| { + if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; + if (std.mem.endsWith(u8, path, source.path)) return source; + } + return null; +} + +pub const platform_has_fs = !pardes.isolated and switch (pardes.platform) { + .tty, .gui, .macos => true, + .web, .esp32p4 => false, +}; + +const find_max_hits = 512; +const find_max_depth = 16; +const find_max_steps = 100_000; +pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); + +const find_skip = [_][]const u8{ + ".git", ".jj", "target", "node_modules", + ".venv", "__pycache__", ".zig-cache", "zig-out", +}; + +pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { + var hits: [find_max_hits][]const u8 = undefined; + var hits_len: usize = 0; + if (platform_has_fs) { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); + defer root.close(io); + var w = try root.walkSelectively(arena); + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and hits_len < hits.len) { + steps += 1; // an unreadable dir burns a step too, so it cannot spin + const e = (try w.next(io)) orelse break; + if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { + hits[hits_len] = try arena.dupe(u8, e.path); + hits_len += 1; + } + if (e.kind != .directory or e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + try w.enter(io, e); + } + } else { + for (sources) |s| { + if (hits_len >= hits.len) break; + if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { + hits[hits_len] = s.path; + hits_len += 1; + } + } + } + std.mem.sort([]const u8, hits[0..hits_len], {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + var written: usize = 0; + for (hits[0..hits_len]) |h| { + if (h.len + 1 > out.len - written) break; + @memcpy(out[written..][0..h.len], h); + written += h.len; + out[written] = '\n'; + written += 1; + } + return written; +} + +const grep_max_bytes = 256 * 1024; +const grep_max_files = 20_000; + +const GrepResult = struct { bytes: usize, hits: usize }; + +fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { + var result: GrepResult = .{ .bytes = 0, .hits = 0 }; + var line: usize = 0; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + line += 1; + if (result.hits >= budget) break; + const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; + const ln = std.mem.trimEnd(u8, raw, " \t\r"); + var cut = @min(ln.len, 200); + while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; + const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ + path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], + }) catch break; + result.bytes += row.len; + result.hits += 1; + } + return result; +} + +pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { + var hits: usize = 0; + var written: usize = 0; + if (!platform_has_fs) { + for (sources) |s| { + if (hits >= find_max_hits or written == out.len) break; + const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); + hits += result.hits; + written += result.bytes; + } + return written; + } + const root_path = std.mem.trimEnd(u8, dir, "/"); + const home = std.mem.trimEnd(u8, base, "/"); + const files = try arena.alloc([]const u8, grep_max_files); + var files_len: usize = 0; + { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); + defer root.close(io); + var w = try root.walkSelectively(arena); + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and files_len < files.len) { + steps += 1; + const e = (try w.next(io)) orelse break; + if (e.kind == .directory) { + if (e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + try w.enter(io, e); + continue; + } + if (e.kind != .file) continue; + files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); + files_len += 1; + } + } + std.mem.sort([]const u8, files[0..files_len], {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + const buf = try gpa.alloc(u8, grep_max_bytes); + defer gpa.free(buf); + for (files[0..files_len]) |path| { + if (hits >= find_max_hits or written == out.len) break; + var pathbuf: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); + if (fd < 0) continue; + var len: usize = 0; + var readable = true; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + readable = false; + break; + } + if (n == 0) break; + len += @intCast(n); + } + _ = libc.close(fd); + if (!readable) continue; + const text = buf[0..len]; + if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; + const shown = lsp.rel(home, path); + const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); + hits += result.hits; + written += result.bytes; + } + return written; +} + +test "grep skips a file it cannot read instead of abandoning the search" { + if (!platform_has_fs) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); + var locked_buf: [std.fs.max_path_bytes]u8 = undefined; + const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); + if (libc.chmod(locked, 0) != 0) return; + const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); + if (probe >= 0) { + _ = libc.close(probe); + _ = libc.chmod(locked, 0o644); + return error.SkipZigTest; + } + + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const out = try gpa.alloc(u8, 64 * 1024); + defer gpa.free(out); + const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); + _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it + + try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); +} + +fn isDir(path: [*:0]const u8) bool { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); + if (fd < 0) return false; + _ = libc.close(fd); + return true; +} + +pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { + return readFileLimit(gpa, path, limits.max_file_bytes); +} + +fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { + if (std.mem.indexOfScalar(u8, path, 0) != null) return error.OpenFailed; + if (!platform_has_fs or std.mem.startsWith(u8, path, "/virtual/")) { + const archive_path = if (std.mem.startsWith(u8, path, "/virtual/")) path[9..] else path; + var normalized_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(archive_path, &normalized_buf) orelse return error.OpenFailed; + const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; + if (source.contents.len > limit) return error.FileTooLarge; + return gpa.dupe(u8, source.contents); + } + var pathbuf: [4096]u8 = undefined; + const native = localPath(path) orelse return error.OpenFailed; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{native}, 0) catch return error.PathTooLong; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); + if (fd < 0) return switch (libc.errno(fd)) { + .ACCES, .PERM => error.PermissionDenied, + .NOENT => error.FileNotFound, + .ISDIR => error.IsDirectory, + .NAMETOOLONG => error.PathTooLong, + else => error.OpenFailed, + }; + defer _ = libc.close(fd); + + const end = libc.lseek(fd, 0, libc.SEEK.END); + if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; + const size: usize = if (end < 0) 0 else @intCast(end); + if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; + if (size == 0) { + const max_bytes = @min(limit, limits.max_stream_bytes); + var stream: std.Io.Writer.Allocating = .init(gpa); + errdefer stream.deinit(); + var chunk: [16 * 1024]u8 = undefined; + while (true) { + const n = libc.read(fd, &chunk, @min(chunk.len, max_bytes - stream.written().len + 1)); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + if (libc.errno(n) == .AGAIN) { + if (stream.written().len == 0) return error.NotAFile; + return stream.toOwnedSlice(); + } + return error.ReadFailed; + } + if (n == 0) return stream.toOwnedSlice(); + const received: usize = @intCast(n); + if (received > max_bytes - stream.written().len) return error.FileTooLarge; + try stream.writer.writeAll(chunk[0..received]); + } + } + if (size > limit) return error.FileTooLarge; + var buf = try gpa.alloc(u8, size); + errdefer gpa.free(buf); + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + len += @intCast(n); + } + if (len != buf.len) buf = try gpa.realloc(buf, len); + return buf; +} + +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const MAX_PANES = pardes.MAX_PANES; + +pub const namespace_root: u64 = 1 << 63; +pub const namespace_panes: u64 = namespace_root + 1; +pub const os_root: u64 = namespace_root + 2; +pub const os_node: u64 = 1 << 62; +const factory_base: u64 = namespace_root + 256; + +pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { + var path_buf: [4096]u8 = undefined; + const normalized = normalizeVirtualPath(path, &path_buf) orelse return null; + var parts = std.mem.tokenizeScalar(u8, normalized, '/'); + const first = parts.next() orelse return @intFromEnum(SelfFile.root); + if (!std.mem.eql(u8, first, "pane")) { + const top = topFileNamed(first) orelse return archiveNode(normalized); + if (parts.next()) |name| { + if (top != .new or parts.next() != null) return null; + const file = paneFileNamed(name) orelse return null; + if (file.inPty()) return null; + return factory_base + @intFromEnum(file); + } + return @intFromEnum(top); + } + const serial = serialNamed(parts.next() orelse return namespace_panes) orelse return null; + const id = p.paneBySerial(serial) orelse return null; + const file = paneFileNamed(parts.next() orelse return Node.of(serial, .dir)) orelse return null; + if (file.inPty() and !p.panes[id].?.isTerminal()) return null; + if (parts.next()) |name| { + if (file != .pty) return null; + const child = ptyFileNamed(name) orelse return null; + if (parts.next() != null) return null; + return Node.of(serial, child); + } + return Node.of(serial, file); +} + +fn namespace(p: *Pardes, req: Req) Reply { + switch (req.op) { + .getattr => return .{ .tag = req.tag, .attr = .{ .node = req.node, .dir = true, .mode = 0o500 } }, + .open => return .{ .tag = req.tag, .handle = 1 }, + .release => return .{ .tag = req.tag }, + .lookup => { + if (std.mem.eql(u8, req.data, "..")) return handle(p, .{ + .tag = req.tag, + .op = .getattr, + .node = if (req.node == namespace_root) namespace_root else @intFromEnum(SelfFile.root), + }); + if (req.node == namespace_root) { + if (std.mem.eql(u8, req.data, "self")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = @intFromEnum(SelfFile.root) }); + if (std.mem.eql(u8, req.data, "os")) return handle(p, .{ .tag = req.tag, .op = .getattr, .node = os_root }); + } else { + const serial = serialNamed(req.data) orelse return Reply.fail(req.tag, E.NOENT); + if (serial == 0) return Reply.fail(req.tag, E.NOENT); + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = Node.of(serial, .dir) }); + } + return Reply.fail(req.tag, E.NOENT); + }, + .readdir => { + const out = p.fs.stage(p.gpa); + var skip = req.off; + if (req.node == namespace_root) { + if (skip == 0) stageDirent(out, p.gpa, os_root, true, "os") else skip -= 1; + if (skip == 0) stageDirent(out, p.gpa, @intFromEnum(SelfFile.root), true, "self"); + } else { + var last: u32 = 0; + while (nextSerialAfter(p, last)) |serial| { + last = serial; + if (skip > 0) { + skip -= 1; + continue; + } + var buf: [16]u8 = undefined; + const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; + stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); + } + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; + }, + else => return Reply.fail(req.tag, E.PERM), + } +} + +pub const Op = enum(u8) { + lookup, + getattr, + setattr, + open, + read, + write, + release, + readdir, +}; + +pub const Status = enum(u8) { + ok, + again, + err, +}; + +pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + + pub fn changesPane(req: Req) bool { + return switch (req.op) { + .write, .setattr => true, + .lookup => req.node == @intFromEnum(SelfFile.new) and !std.mem.eql(u8, req.data, ".."), + .open => req.node >= factory_base and req.node < factory_base + 16, + .getattr, .read, .release, .readdir => false, + }; + } +}; + +pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + payload: Payload = .none, + written: u32 = 0, + + pub const Attr = struct { + name: []const u8 = "", + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o600, + }; + + pub const Payload = union(enum) { + none, + staged: u32, + region: struct { pane: u8, serial: u32, off: u32, len: u32 }, + }; + + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } +}; + +pub const E = struct { + pub const PERM: u16 = 1; + pub const NOENT: u16 = 2; + pub const IO: u16 = 5; + pub const NOMEM: u16 = 12; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; + pub const NFILE: u16 = 23; + pub const NOSPC: u16 = 28; + pub const NOSYS: u16 = 38; +}; + +pub const PaneFile = enum(u4) { + dir = 0, + addr, + body, + ctl, + data, + errors, + event, + tag, + xdata, + rdsel, + wrsel, + pty, + pty_ctl, + pty_status, + pty_data, + + pub fn name(f: PaneFile) []const u8 { + return switch (f) { + .dir => ".", + .pty_ctl => "ctl", + .pty_status => "status", + .pty_data => "data", + else => @tagName(f), + }; + } + + pub fn mode(f: PaneFile) u16 { + return switch (f) { + .dir, .pty => 0o500, + .errors, .wrsel, .pty_ctl => 0o200, + .rdsel, .pty_status => 0o400, + else => 0o600, + }; + } + + pub fn isDir(f: PaneFile) bool { + return f == .dir or f == .pty; + } + + pub fn inPty(f: PaneFile) bool { + return switch (f) { + .pty, .pty_ctl, .pty_status, .pty_data => true, + else => false, + }; + } +}; + +pub const SelfFile = enum(u4) { + root = 1, + index = 2, + cons = 3, + new = 4, + screen = 5, + listeners = 6, + + pub fn name(f: SelfFile) []const u8 { + return if (f == .root) "." else @tagName(f); + } + + pub fn mode(f: SelfFile) u16 { + return switch (f) { + .root, .new => 0o500, + .index, .screen, .listeners => 0o400, + .cons => 0o200, + }; + } + + pub fn dir(f: SelfFile) bool { + return f == .root or f == .new; + } +}; + +pub const Node = packed struct(u64) { + file: u4 = 0, + serial: u60 = 0, + + pub fn of(serial: u32, file: PaneFile) u64 { + std.debug.assert(serial != 0); + return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); + } + + pub fn target(node: u64) ?Target { + const n: Node = @bitCast(node); + if (n.serial == 0) { + return .{ .top = std.enums.fromInt(SelfFile, n.file) orelse return null }; + } + return .{ .pane = .{ + .serial = std.math.cast(u32, n.serial) orelse return null, + .file = std.enums.fromInt(PaneFile, n.file) orelse return null, + } }; + } +}; + +pub const Target = union(enum) { + top: SelfFile, + pane: struct { serial: u32, file: PaneFile }, +}; + +pub const out_reserve = 4 * 1024; + +pub const queue_cap = 64 * 1024; + +pub const Queue = struct { + buf: std.ArrayList(u8) = .empty, + head: usize = 0, + + pub fn deinit(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.deinit(gpa); + q.head = 0; + } + + pub fn push(q: *Queue, gpa: std.mem.Allocator, record: []const u8) void { + if (record.len > std.math.maxInt(u32)) return; + while (q.buf.items.len - q.head + record.len + 4 > queue_cap) { + if (q.peek() == null) return; + q.pop(); + } + q.compact(); + var head: [4]u8 = undefined; + std.mem.writeInt(u32, &head, @intCast(record.len), .little); + q.buf.appendSlice(gpa, &head) catch return; + q.buf.appendSlice(gpa, record) catch { + q.buf.shrinkRetainingCapacity(q.buf.items.len - 4); + return; + }; + } + + pub fn peek(q: *const Queue) ?[]const u8 { + const rest = q.buf.items[@min(q.head, q.buf.items.len)..]; + if (rest.len < 4) return null; + const len = std.mem.readInt(u32, rest[0..4], .little); + if (rest.len < 4 + len) return null; + return rest[4 .. 4 + len]; + } + + pub fn pop(q: *Queue) void { + const record = q.peek() orelse return; + q.head += 4 + record.len; + if (q.head == q.buf.items.len) { + q.buf.clearRetainingCapacity(); + q.head = 0; + } + } + + pub fn popFront(q: *Queue, n: usize) void { + const record = q.peek() orelse return; + if (n >= record.len) return q.pop(); + q.head += n; + std.mem.writeInt(u32, q.buf.items[q.head..][0..4], @intCast(record.len - n), .little); + } + + fn compact(q: *Queue) void { + if (q.head == 0 or q.head * 2 < q.buf.items.len) return; + const rest = q.buf.items.len - q.head; + std.mem.copyForwards(u8, q.buf.items[0..rest], q.buf.items[q.head..]); + q.buf.shrinkRetainingCapacity(rest); + q.head = 0; + } + + pub fn empty(q: *const Queue) bool { + return q.peek() == null; + } + + pub fn clearAndFree(q: *Queue, gpa: std.mem.Allocator) void { + q.buf.clearAndFree(gpa); + q.head = 0; + } +}; + +pub const PaneState = struct { + addr: Range = .{}, + limit: ?Range = null, + readers: u16 = 0, + events: Queue = .{}, + nomark: bool = false, + noscroll: bool = false, + tag_snap: std.ArrayList(u8) = .empty, + pty_readers: u16 = 0, + pty_out: Queue = .{}, + + pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; + + fn deinit(pf: *PaneState, gpa: std.mem.Allocator) void { + pf.events.deinit(gpa); + pf.pty_out.deinit(gpa); + pf.tag_snap.deinit(gpa); + pf.* = .{}; + } +}; + +pub const Namespace = struct { + socket_path: []const u8 = "", + tcp_address: ?std.Io.net.IpAddress = null, + quic_address: ?std.Io.net.IpAddress = null, + mounts: std.ArrayList(Mount) = .empty, + node_name: [16]u8 = undefined, + os_paths: std.ArrayList(OsPath) = .empty, + snapshots: [32]struct { node: u64 = 0, bytes: ?[]const u8 = null } = @splat(.{}), + out: std.ArrayList(u8) = .empty, + panes: [MAX_PANES]PaneState = @splat(.{}), + listeners: u16 = 0, + origin: u8 = 'K', + + pub fn deinit(st: *Namespace, gpa: std.mem.Allocator) void { + for (st.mounts.items) |entry| { + gpa.free(entry.name); + gpa.free(entry.dial); + } + st.mounts.deinit(gpa); + for (st.os_paths.items) |entry| gpa.free(entry.path); + st.os_paths.deinit(gpa); + for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes); + for (&st.panes) |*pf| pf.deinit(gpa); + st.out.deinit(gpa); + } + + pub fn mount(st: *Namespace, gpa: std.mem.Allocator, name: []const u8, dial: []const u8) !void { + if (!validMountName(name)) return error.BadMountName; + if (comptime pardes.hosted) try ninep_io.Client.validateDial(dial); + if (dial.len == 0 or std.mem.indexOfScalar(u8, dial, 0) != null) return error.BadDial; + for (st.mounts.items) |entry| if (std.mem.eql(u8, name, entry.name)) return error.AlreadyMounted; + if (st.mounts.items.len == max_mounts) return error.TooManyMounts; + const saved_name = try gpa.dupe(u8, name); + errdefer gpa.free(saved_name); + const saved_dial = try gpa.dupe(u8, dial); + errdefer gpa.free(saved_dial); + try st.mounts.append(gpa, .{ .name = saved_name, .dial = saved_dial }); + } + + pub fn stage(st: *Namespace, gpa: std.mem.Allocator) *std.ArrayList(u8) { + st.out.clearRetainingCapacity(); + st.out.ensureTotalCapacity(gpa, out_reserve) catch {}; + return &st.out; + } + + pub fn forget(st: *Namespace, gpa: std.mem.Allocator, id: usize) void { + if (id >= MAX_PANES) return; + st.listeners -= @min(st.listeners, st.panes[id].readers); + st.panes[id].deinit(gpa); + } + + pub fn scripted(st: *const Namespace, id: usize) bool { + return id < MAX_PANES and st.panes[id].readers != 0; + } +}; + +pub fn unmount(p: *Pardes, name: []const u8) !void { + for (p.fs.mounts.items, 0..) |entry, index| { + if (!std.mem.eql(u8, name, entry.name)) continue; + var prefix_buf: [name_capacity + 4]u8 = undefined; + const prefix = try std.fmt.bufPrint(&prefix_buf, "/n/{s}", .{name}); + for (p.panes) |slot| { + const pane = slot orelse continue; + const paths = [_][]const u8{ + if (pane.file) |file| file.path else "", + if (comptime pardes.pdf_enabled) (if (pane.pdf) |pdf| pdf.path else "") else "", + if (pane.image) |image| image.path else "", + Pardes.paneDir(pane), + }; + for (paths) |path| if (std.mem.startsWith(u8, path, prefix) and + (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; + } + for (0..p.effects_len) |i| { + const effect = p.effects[(p.effects_head + i) % p.effects.len]; + if (effect != .save_text) continue; + const path = effect.save_text.path.slice(); + if (std.mem.startsWith(u8, path, prefix) and + (path.len == prefix.len or path[prefix.len] == '/')) return error.MountInUse; + } + const removed = p.fs.mounts.orderedRemove(index); + p.gpa.free(removed.name); + p.gpa.free(removed.dial); + return; + } + return error.NotMounted; +} + +pub const max_record_text = 256; + +pub const Action = enum(u8) { + body_delete = 'D', + tag_delete = 'd', + body_insert = 'I', + tag_insert = 'i', + body_look = 'L', + tag_look = 'l', + body_exec = 'X', + tag_exec = 'x', + + pub fn char(a: Action) u8 { + return @intFromEnum(a); + } + + pub fn fromChar(c: u8) ?Action { + return std.enums.fromInt(Action, c); + } + + pub fn onTag(a: Action) bool { + return @intFromEnum(a) >= 'a'; + } +}; + +pub const flag_builtin: u32 = 1; +pub const flag_expansion: u32 = 2; +pub const flag_filename: u32 = 4; +pub const flag_chorded: u32 = 8; + +pub fn formatRecord( + buf: []u8, + origin: u8, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) []const u8 { + const sent = if (text.len >= max_record_text) text[0..0] else text; + return std.fmt.bufPrint(buf, "{c}{c}{d} {d} {d} {d} {s}\n", .{ + origin, + action.char(), + q0, + q1, + flag, + sent.len, + sent, + }) catch buf[0..0]; +} + +pub const Span = struct { at: u32, removed: u32, inserted: u32 }; + +pub fn diffSpan(old: []const u8, new: []const u8) Span { + const both = @min(old.len, new.len); + const stride = 64; + var head: usize = 0; + while (head + stride <= both and + std.mem.eql(u8, old[head..][0..stride], new[head..][0..stride])) head += stride; + while (head < both and old[head] == new[head]) head += 1; + var tail: usize = 0; + const rest = both - head; + while (tail + stride <= rest and std.mem.eql( + u8, + old[old.len - tail - stride ..][0..stride], + new[new.len - tail - stride ..][0..stride], + )) tail += stride; + while (tail < rest and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1; + return .{ + .at = @intCast(head), + .removed = @intCast(old.len - tail - head), + .inserted = @intCast(new.len - tail - head), + }; +} + +pub fn noteReplace(p: *Pardes, id: usize, on_tag: bool, old: []const u8, new: []const u8) void { + if (!p.fs.scripted(id)) return; + const span = diffSpan(old, new); + if (span.removed == 0 and span.inserted == 0) return; + if (span.removed > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_delete else .body_delete, + span.at, + span.at + span.removed, + 0, + "", + ); + if (span.inserted > 0) _ = noteAction( + p, + id, + if (on_tag) .tag_insert else .body_insert, + span.at, + span.at + span.inserted, + 0, + new[span.at..][0..span.inserted], + ); +} + +pub fn noteAction( + p: *Pardes, + id: usize, + action: Action, + q0: u32, + q1: u32, + flag: u32, + text: []const u8, +) bool { + if (!p.fs.scripted(id)) return false; + var buf: [max_record_text + 64]u8 = undefined; + const record = formatRecord(&buf, p.fs.origin, action, q0, q1, flag, text); + p.fs.panes[id].events.push(p.gpa, record); + return true; +} + +pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { + if (id >= MAX_PANES or bytes.len == 0) return; + const pf = &p.fs.panes[id]; + if (pf.pty_readers == 0) return; + var off: usize = 0; + while (off < bytes.len) { + const n = @min(bytes.len - off, queue_cap / 2); + pf.pty_out.push(p.gpa, bytes[off..][0..n]); + off += n; + } +} + +pub fn handle(p: *Pardes, req: Req) Reply { + if (req.node >= factory_base and req.node < factory_base + 16) { + const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .getattr) return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; + if (req.op != .open) return Reply.fail(req.tag, E.PERM); + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + const node = Node.of(serial, file); + var reply = handle(p, .{ .tag = req.tag, .op = .open, .node = node }); + reply.attr.node = node; + return reply; + } + if (req.node == namespace_root or req.node == namespace_panes) return namespace(p, req); + if (req.node == os_root or req.node & os_node != 0) return osHandle(p, req); + if (req.node & archive_node != 0) return archiveHandle(p, req); + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { + .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), + .top => 'F', + }; + return switch (req.op) { + .lookup => lookup(p, req, target), + .getattr => switch (attrOf(p, target)) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }, + .setattr => setattr(p, req, target), + .open => open(p, req, target), + .release => release(p, req), + .readdir => readdir(p, req, target), + .read => handleRead(p, req, target), + .write => handleWrite(p, req, target), + }; +} + +const AttrResult = union(enum) { ok: Reply.Attr, missing }; + +fn attrOf(p: *Pardes, target: Target) AttrResult { + switch (target) { + .top => |f| return .{ .ok = .{ + .name = if (f == .root) "self" else f.name(), + .node = @intFromEnum(f), + .dir = f.dir(), + .mode = f.mode(), + .size = topSize(p, f), + } }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return .missing; + if (t.file.inPty() and !p.panes[id].?.isTerminal()) return .missing; + return .{ .ok = .{ + .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.name(), + .node = Node.of(t.serial, t.file), + .dir = t.file.isDir(), + .mode = t.file.mode(), + .size = paneFileSize(p, id, t.file), + } }; + }, + } +} + +fn topSize(p: *Pardes, f: SelfFile) u64 { + return switch (f) { + .root, .new, .cons, .screen, .listeners => 0, + .index => indexLen(p), + }; +} + +fn paneFileSize(p: *Pardes, id: usize, f: PaneFile) u64 { + const pane = p.panes[id] orelse return 0; + return switch (f) { + .body, .data, .xdata => bodyLen(p, pane), + .tag => tagLen(p, pane), + .dir, .addr, .ctl, .errors, .event, .rdsel, .wrsel => 0, + .pty, .pty_ctl, .pty_status, .pty_data => 0, + }; +} + +fn bodyOf(pane: *const Pane) []const u8 { + if (pane.file) |*f| return f.content; + return ""; +} + +fn fileOf(pane: *Pane) ?*panes.File.State { + return if (pane.file) |*f| f else null; +} + +fn tagOf(p: *Pardes, pane: *Pane) []const u8 { + return p.tagText(p.scratch.allocator(), pane) catch ""; +} + +fn dirOf(pane: *Pane) []const u8 { + if (pane.file) |*f| return std.fs.path.dirname(f.path) orelse "/"; + const cwd = pane.cwdSlice(); + return if (cwd.len > 0) cwd else "/"; +} + +fn dirtyOf(pane: *const Pane) bool { + const f = if (pane.file) |*x| x else return false; + if (!panes.Output.fileTraits(f.output).saves) return false; + return f.revision != f.saved_revision; +} + +fn clip(n: usize) u32 { + return std.math.cast(u32, n) orelse std.math.maxInt(u32); +} + +fn cellOf(row: i32, col: i32) modal.Cursor { + return .{ .row = @intCast(@max(0, row)), .col = @intCast(@max(0, col)) }; +} + +fn firstLine(s: []const u8) []const u8 { + return s[0 .. std.mem.indexOfScalar(u8, s, '\n') orelse s.len]; +} + +fn dotOf(pane: *Pane) PaneState.Range { + const text = bodyOf(pane); + const head = modal.offsetAt(text, cellOf(pane.cur_row, pane.cur_col)); + if (!pane.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; + const anchor = modal.offsetAt(text, cellOf(pane.vsel.row, pane.vsel.col)); + var hi = @max(head, anchor); + if (hi < text.len) hi = modal.nextGrapheme(text, hi); + return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; +} + +fn setDot(pane: *Pane, r: PaneState.Range) void { + const text = bodyOf(pane); + const q0 = @min(@as(usize, r.q0), text.len); + const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); + const a = modal.positionAt(text, q0); + pane.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; + const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); + pane.cur_row = @intCast(h.row); + pane.cur_col = @intCast(h.col); + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.msel.active = false; + pane.ensureCursorVisible(); +} + +fn showOffset(pane: *Pane, off: usize) void { + const text = bodyOf(pane); + const c = modal.positionAt(text, @min(off, text.len)); + pane.cur_row = @intCast(c.row); + pane.cur_col = @intCast(c.col); + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); +} + +fn clampAddr(pf: *PaneState, len: usize) void { + const n = clip(len); + pf.addr.q0 = @min(pf.addr.q0, n); + pf.addr.q1 = @min(pf.addr.q1, n); + if (pf.limit) |*l| { + l.q0 = @min(l.q0, n); + l.q1 = @min(l.q1, n); + } +} + +fn shiftBy(r: PaneState.Range, at: u32, removed: u32, inserted: u32) PaneState.Range { + return .{ .q0 = shiftOne(r.q0, at, removed, inserted), .q1 = shiftOne(r.q1, at, removed, inserted) }; +} + +fn shiftOne(v: u32, at: u32, removed: u32, inserted: u32) u32 { + if (v <= at) return v; + if (v <= at +| removed) return at +| inserted; + return v - removed +| inserted; +} + +fn wholeUtf8(data: []const u8) usize { + var i = data.len; + var back: usize = 0; + while (i > 0 and back < 4) : (back += 1) { + i -= 1; + const c = data[i]; + if (c < 0x80) return data.len; + if (c & 0xC0 == 0xC0) { + const need = std.unicode.utf8ByteSequenceLength(c) catch return data.len; + if (i + need <= data.len or i == 0) return data.len; + return i; + } + } + return data.len; +} + +fn paneFileNamed(name: []const u8) ?PaneFile { + const f = std.meta.stringToEnum(PaneFile, name) orelse return null; + if (f == .dir) return null; + return if (f.inPty() and f != .pty) null else f; +} + +fn ptyFileNamed(name: []const u8) ?PaneFile { + if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; + if (std.mem.eql(u8, name, "status")) return .pty_status; + if (std.mem.eql(u8, name, "data")) return .pty_data; + return null; +} + +fn topFileNamed(name: []const u8) ?SelfFile { + const f = std.meta.stringToEnum(SelfFile, name) orelse return null; + return if (f == .root) null else f; +} + +fn serialNamed(name: []const u8) ?u32 { + if (name.len == 0 or name.len > 10) return null; + for (name) |c| if (c < '0' or c > '9') return null; + return std.fmt.parseInt(u32, name, 10) catch null; +} + +fn nextSerialAfter(p: *Pardes, after: u32) ?u32 { + var best: ?u32 = null; + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.serial <= after) continue; + if (best == null or pane.serial < best.?) best = pane.serial; + } + return best; +} + +fn newPane(p: *Pardes) ?u32 { + const slot = p.freeSlot() orelse return null; + p.newScratchBelow(p.active); + const pane = p.panes[slot] orelse return null; + return pane.serial; +} + +fn lookup(p: *Pardes, req: Req, target: Target) Reply { + const name = req.data; + if (std.mem.eql(u8, name, "..")) { + const parent: u64 = switch (target) { + .top => |top| if (top == .root) namespace_root else @intFromEnum(SelfFile.root), + .pane => |t| if (t.file == .pty) Node.of(t.serial, .dir) else namespace_panes, + }; + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); + } + if (name.len == 0 or std.mem.indexOfScalar(u8, name, '/') != null) return Reply.fail(req.tag, E.NOENT); + const node: u64 = switch (target) { + .top => |f| switch (f) { + .root => root: { + if (std.mem.eql(u8, name, "pane")) return .{ .tag = req.tag, .attr = .{ .node = namespace_panes, .dir = true, .mode = 0o500 } }; + if (topFileNamed(name)) |t| break :root @intFromEnum(t); + if (archiveNode(name)) |node| return archiveHandle(p, .{ .tag = req.tag, .op = .getattr, .node = node }); + return Reply.fail(req.tag, E.NOENT); + }, + .new => new: { + const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); + if (want.inPty()) return Reply.fail(req.tag, E.NOENT); + const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); + break :new Node.of(serial, want); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| pane: { + _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const f = switch (t.file) { + .dir => paneFileNamed(name), + .pty => ptyFileNamed(name), + else => return Reply.fail(req.tag, E.NOTDIR), + } orelse return Reply.fail(req.tag, E.NOENT); + break :pane Node.of(t.serial, f); + }, + }; + return switch (attrOf(p, Node.target(node) orelse return Reply.fail(req.tag, E.NOENT))) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }; +} + +pub fn stageDirent(out: *std.ArrayList(u8), gpa: std.mem.Allocator, node: u64, dir: bool, name: []const u8) void { + if (name.len == 0 or name.len > 255) return; + var head: [10]u8 = undefined; + std.mem.writeInt(u64, head[0..8], node, .little); + head[8] = @intFromBool(dir); + head[9] = @intCast(name.len); + out.appendSlice(gpa, &head) catch return; + out.appendSlice(gpa, name) catch return; +} + +fn stagePaneFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, terminal: bool, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime f == .dir or (f.inPty() and f != .pty)) continue; + const present = f != .pty or terminal; + if (present) { + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), f.isDir(), f.name()); + } + } +} + +fn stagePtyFiles(p: *Pardes, out: *std.ArrayList(u8), serial: u32, skip: *u64) void { + inline for (comptime std.enums.values(PaneFile)) |f| { + if (comptime !f.inPty() or f == .pty) continue; + if (skip.* > 0) skip.* -= 1 else stageDirent(out, p.gpa, Node.of(serial, f), false, f.name()); + } +} + +fn readdir(p: *Pardes, req: Req, target: Target) Reply { + const out = p.fs.stage(p.gpa); + var skip = req.off; + switch (target) { + .top => |f| switch (f) { + .root => { + inline for (.{ SelfFile.index, SelfFile.cons, SelfFile.new }) |t| { + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.name()); + } + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane"); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen"); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners"); + stageArchive(p, out, "", &skip); + }, + .new => {}, + else => return Reply.fail(req.tag, E.NOTDIR), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const terminal = p.panes[id].?.isTerminal(); + switch (t.file) { + .dir => stagePaneFiles(p, out, t.serial, terminal, &skip), + .pty => { + if (!terminal) return Reply.fail(req.tag, E.NOENT); + stagePtyFiles(p, out, t.serial, &skip); + }, + else => return Reply.fail(req.tag, E.NOTDIR), + } + }, + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn open(p: *Pardes, req: Req, target: Target) Reply { + const snapshot = switch (target) { + .top => |f| f == .screen, + .pane => |t| t.file == .body and if (p.paneBySerial(t.serial)) |id| p.panes[id].?.isTerminal() else false, + }; + if (snapshot) { + for (&p.fs.snapshots, 0..) |*slot, i| { + if (slot.node != 0) continue; + const bytes = if (target == .top) screenSnapshot(p) catch return Reply.fail(req.tag, E.NOMEM) else null; + slot.* = .{ .node = req.node, .bytes = bytes }; + return .{ .tag = req.tag, .handle = @intCast(i + 1) }; + } + return Reply.fail(req.tag, E.NFILE); + } + switch (target) { + .top => {}, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !p.panes[id].?.isTerminal()) return Reply.fail(req.tag, E.NOENT); + switch (t.file) { + .ctl => pf.limit = null, + .addr => { + pf.addr = .{}; + pf.limit = null; + }, + .event => { + pf.readers +|= 1; + p.fs.listeners +|= 1; + }, + .pty_data => pf.pty_readers +|= 1, + else => {}, + } + }, + } + return .{ .tag = req.tag, .handle = 1 }; +} + +fn release(p: *Pardes, req: Req) Reply { + if (req.handle > 0 and req.handle <= p.fs.snapshots.len) { + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node == req.node) { + if (snapshot.bytes) |bytes| p.gpa.free(bytes); + snapshot.* = .{}; + return .{ .tag = req.tag }; + } + } + const target = Node.target(req.node) orelse return .{ .tag = req.tag }; + switch (target) { + .top => {}, + .pane => |t| { + if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; + const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; + const pf = &p.fs.panes[id]; + if (t.file == .pty_data) { + if (pf.pty_readers == 0) return .{ .tag = req.tag }; + pf.pty_readers -= 1; + if (pf.pty_readers == 0) pf.pty_out.clearAndFree(p.gpa); + return .{ .tag = req.tag }; + } + if (pf.readers == 0) return .{ .tag = req.tag }; + pf.readers -= 1; + p.fs.listeners -|= 1; + if (pf.readers == 0) pf.tag_snap.clearAndFree(p.gpa); + }, + } + return .{ .tag = req.tag }; +} + +fn setattr(p: *Pardes, req: Req, target: Target) Reply { + if (req.truncate) switch (target) { + .pane => |t| switch (t.file) { + .body, .data, .xdata => { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + if (fileOf(pane) != null) { + _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse + return Reply.fail(req.tag, E.NOMEM); + p.fs.panes[id].addr = .{}; + setDot(pane, .{}); + } + }, + else => {}, + }, + else => {}, + }; + return switch (attrOf(p, target)) { + .ok => |a| .{ .tag = req.tag, .attr = a }, + .missing => Reply.fail(req.tag, E.NOENT), + }; +} + +fn staged(p: *Pardes, req: Req) Reply { + const out = &p.fs.out; + const off = @min(req.off, out.items.len); + const n = @min(out.items.len - off, req.size); + if (off > 0) std.mem.copyForwards(u8, out.items[0..n], out.items[off..][0..n]); + out.shrinkRetainingCapacity(n); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; +} + +fn handleRead(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .index => readIndex(p, req), + .listeners => listeners: { + var buf: [512]u8 = undefined; + var text = std.Io.Writer.fixed(&buf); + if (p.fs.socket_path.len != 0) + text.print("unix!{s}\n", .{p.fs.socket_path}) catch break :listeners Reply.fail(req.tag, E.IO); + for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { + const address = maybe orelse continue; + switch (address) { + .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch + break :listeners Reply.fail(req.tag, E.IO), + .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch + break :listeners Reply.fail(req.tag, E.IO), + } + } + const bytes = text.buffered(); + const off = @min(req.off, bytes.len); + const len = @min(bytes.len - off, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, bytes[off..][0..len]) catch break :listeners Reply.fail(req.tag, E.NOMEM); + break :listeners .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; + }, + .screen => readSnapshot(p, req, null), + .cons, .root, .new => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + const pf = &p.fs.panes[id]; + if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return switch (t.file) { + .addr => readAddr(p, req, pf, pane), + .body => readBody(p, req, id, pane), + .ctl => readCtl(p, req, pane), + .data => readData(req, id, pane, pf, false), + .xdata => readData(req, id, pane, pf, true), + .tag => readTag(p, req, pane), + .event => readQueue(p, req, &pf.events), + .rdsel => readRdsel(req, id, pane), + .pty_status => readPtyStatus(p, req, id, pane), + .pty_data => readPtyData(p, req, pf), + .dir, .errors, .wrsel, .pty, .pty_ctl => Reply.fail(req.tag, E.PERM), + }; + }, + } +} + +fn screenSnapshot(p: *Pardes) ![]u8 { + var arena: std.heap.ArenaAllocator = .init(p.gpa); + defer arena.deinit(); + const surface = try p.render(arena.allocator()); + var styles: std.ArrayList(pardes.CellStyle) = .empty; + var indices: std.ArrayList(usize) = .empty; + for (surface.cells) |cell| { + const style: pardes.CellStyle = if (cell.default) .{} else cell.style; + const index = for (styles.items, 0..) |previous, i| { + if (std.meta.eql(style, previous)) break i; + } else new: { + try styles.append(arena.allocator(), style); + break :new styles.items.len - 1; + }; + try indices.append(arena.allocator(), index); + } + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + const writer = &out.writer; + try writer.print("{{\"cols\":{d},\"rows\":{d},\"cursor\":", .{ surface.cols, surface.rows }); + try std.json.Stringify.value(surface.cursor, .{}, writer); + try writer.writeAll(",\"styles\":"); + try std.json.Stringify.value(styles.items, .{ .emit_strings_as_arrays = true }, writer); + try writer.writeAll(",\"cells\":["); + for (surface.cells, indices.items, 0..) |cell, index, i| { + if (i != 0) try writer.writeByte(','); + try std.json.Stringify.value(.{ if (cell.default) " " else cell.grapheme(), index }, .{}, writer); + } + try writer.writeAll("]}\n"); + return out.toOwnedSlice(); +} + +const ctl_fields = 5 * 12; + +fn stageCtlNumbers(p: *Pardes, out: *std.ArrayList(u8), pane: *Pane) void { + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{ + pane.serial, + tagOf(p, pane).len, + bodyOf(pane).len, + @as(u32, 0), + @intFromBool(dirtyOf(pane)), + }) catch {}; +} + +fn readIndex(p: *Pardes, req: Req) Reply { + const out = p.fs.stage(p.gpa); + var last: u32 = 0; + while (nextSerialAfter(p, last)) |s| { + last = s; + const pane = p.panes[p.paneBySerial(s).?].?; + stageCtlNumbers(p, out, pane); + out.appendSlice(p.gpa, firstLine(tagOf(p, pane))) catch {}; + out.append(p.gpa, '\n') catch {}; + } + return staged(p, req); +} + +fn readAddr(p: *Pardes, req: Req, pf: *PaneState, pane: *Pane) Reply { + clampAddr(pf, bodyOf(pane).len); + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; + return staged(p, req); +} + +fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (pane.file != null) { + const text = bodyOf(pane); + const off = @min(req.off, text.len); + const n = @min(text.len - off, req.size); + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(off), + .len = clip(n), + } } }; + } + if (req.handle != 0 and pane.isTerminal()) return readSnapshot(p, req, pane); + 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); + out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + return staged(p, req); +} + +fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { + if (req.handle == 0 or req.handle > p.fs.snapshots.len) return Reply.fail(req.tag, E.INVAL); + const snapshot = &p.fs.snapshots[req.handle - 1]; + if (snapshot.node == 0 or snapshot.node != req.node) return Reply.fail(req.tag, E.INVAL); + if (snapshot.bytes == null) { + const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); + snapshot.bytes = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); + } + const bytes = snapshot.bytes.?; + const off = @min(req.off, bytes.len); + const len = @min(bytes.len - off, req.size); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, bytes[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } }; +} + +fn fontName(p: *Pardes) []const u8 { + const name = p.settings.font.effective_name.get(); + return if (name.len == 0) "default" else name; +} + +fn stageQuoted(out: *std.ArrayList(u8), gpa: std.mem.Allocator, s: []const u8) void { + const plain = s.len > 0 and for (s) |c| { + if (c <= ' ' or c == '\'') break false; + } else true; + if (plain) { + out.appendSlice(gpa, s) catch {}; + return; + } + out.append(gpa, '\'') catch {}; + for (s) |c| { + if (c == '\'') out.append(gpa, '\'') catch {}; + out.append(gpa, c) catch {}; + } + out.append(gpa, '\'') catch {}; +} + +fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + stageCtlNumbers(p, out, pane); + out.print(p.gpa, "{d:>11} ", .{pane.cols}) catch {}; + stageQuoted(out, p.gpa, fontName(p)); + out.print(p.gpa, " {d:>11} ", .{config.tab_width}) catch {}; + return staged(p, req); +} + +fn readTag(p: *Pardes, req: Req, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; + return staged(p, req); +} + +fn readData(req: Req, id: usize, pane: *Pane, pf: *PaneState, stop_at_end: bool) Reply { + const text = bodyOf(pane); + clampAddr(pf, text.len); + const q0: usize = pf.addr.q0; + const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; + var end = @min(hi, q0 +| req.size); + end = @max(q0, modal.graphemeStart(text, end)); + pf.addr.q0 = clip(end); + if (!stop_at_end) pf.addr.q1 = clip(end); + if (pane.file == null) return .{ .tag = req.tag }; + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(q0), + .len = clip(end - q0), + } } }; +} + +fn readRdsel(req: Req, id: usize, pane: *Pane) Reply { + if (pane.file == null) return .{ .tag = req.tag }; + const text = bodyOf(pane); + const d = dotOf(pane); + const lo = @min(@as(usize, d.q0), text.len); + const hi = @max(lo, @min(@as(usize, d.q1), text.len)); + const off = @min(req.off, hi - lo); + const n = @min(hi - lo - off, req.size); + return .{ .tag = req.tag, .payload = .{ .region = .{ + .pane = @intCast(id), + .serial = pane.serial, + .off = clip(lo + off), + .len = clip(n), + } } }; +} + +fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { + const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; + if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); + q.pop(); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn readPtyStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const out = p.fs.stage(p.gpa); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ + pane.cols, + pane.rows, + @intFromBool(p.hostTtyTaken(id)), + }) catch {}; + return staged(p, req); +} + +fn readPtyData(p: *Pardes, req: Req, pf: *PaneState) Reply { + if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; + const out = p.fs.stage(p.gpa); + while (out.items.len < req.size) { + const chunk = pf.pty_out.peek() orelse break; + const n = @min(chunk.len, req.size - out.items.len); + out.appendSlice(p.gpa, chunk[0..n]) catch break; + pf.pty_out.popFront(n); + } + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; +} + +fn handleWrite(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| return switch (f) { + .cons => if (appendErrors(p, p.active, req.data)) |took| + .{ .tag = req.tag, .written = @intCast(took) } + else + Reply.fail(req.tag, E.IO), + else => Reply.fail(req.tag, E.PERM), + }, + .pane => |t| { + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + const pane = p.panes[id].?; + if (t.file.inPty() and !pane.isTerminal()) return Reply.fail(req.tag, E.NOENT); + return switch (t.file) { + .addr => writeAddr(p, req, id, pane), + .body => writeBody(p, req, id, pane), + .ctl => writeCtl(p, req, t.serial), + .data, .xdata => writeData(p, req, id, pane), + .tag => writeTag(p, req, pane), + .event => writeEvent(p, req, id), + .wrsel => writeWrsel(p, req, id, pane), + .errors => if (appendErrors(p, id, req.data)) |took| + .{ .tag = req.tag, .written = @intCast(took) } + else + Reply.fail(req.tag, E.IO), + .pty_ctl => writePtyCtl(p, req, id), + .pty_data => writePtyData(p, req, id), + .dir, .rdsel, .pty, .pty_status => Reply.fail(req.tag, E.PERM), + }; + }, + } +} + +fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, bytes: []const u8) ?usize { + const f = fileOf(pane) orelse return null; + const take = if (bytes.len == 0) 0 else wholeUtf8(bytes); + const lo = @min(q0, f.content.len); + const hi = @max(lo, @min(q1, f.content.len)); + const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null; + @memcpy(new[0..lo], f.content[0..lo]); + @memcpy(new[lo..][0..take], bytes[0..take]); + @memcpy(new[lo + take ..], f.content[hi..]); + if (!p.fs.panes[id].nomark) panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, new); + return take; +} + +fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + if (pane.file == null) { + const take = wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; + } + const at = bodyOf(pane).len; + const take = spliceBody(p, id, pane, at, at, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + if (!p.fs.panes[id].noscroll) showOffset(pane, at + take); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + p.seedTail(pane); + const room = pane.tag_tail.len - pane.tag_tail_len; + if (room == 0) return Reply.fail(req.tag, E.NOSPC); + const take = wholeUtf8(req.data[0..@min(req.data.len, room)]); + @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]); + pane.tag_tail_len += take; + pane.tag_init = true; + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeData(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + const pf = &p.fs.panes[id]; + clampAddr(pf, bodyOf(pane).len); + const q0: usize = pf.addr.q0; + const q1: usize = @max(q0, @as(usize, pf.addr.q1)); + const before = dotOf(pane); + const take = spliceBody(p, id, pane, q0, q1, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + setDot(pane, shiftBy(before, clip(q0), clip(q1 - q0), clip(take))); + pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }; + if (!pf.noscroll) showOffset(pane, q0 + take); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeWrsel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + const d = dotOf(pane); + const q0: usize = d.q0; + const q1: usize = @max(q0, @as(usize, d.q1)); + const take = spliceBody(p, id, pane, q0, q1, req.data) orelse + return Reply.fail(req.tag, E.NOMEM); + setDot(pane, .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) }); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { + const pf = &p.fs.panes[id]; + const text = bodyOf(pane); + clampAddr(pf, text.len); + const expr = std.mem.trimEnd(u8, req.data, "\n"); + var a: Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; + const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); + if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); + pf.addr = r; + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn safePattern(pat: []const u8) bool { + var i: usize = 0; + while (i < pat.len) : (i += 1) { + if (pat[i] != '\\') continue; + if (i + 1 >= pat.len) return false; + i += 1; + } + return true; +} + +const Addr = struct { + text: []const u8, + lim: ?PaneState.Range, + expr: []const u8, + i: usize = 0, + depth: u8 = 0, + + const max_depth = 32; + const Size = enum { char, line }; + + fn address(a: *Addr, ar_in: PaneState.Range) ?PaneState.Range { + const start = a.i; + var ar = ar_in; + var r = ar_in; + var dir: u8 = 0; + var size: Size = .line; + var c: u8 = 0; + while (a.i < a.expr.len) { + const prevc = c; + c = a.expr[a.i]; + a.i += 1; + switch (c) { + ',', ';' => { + if (c == ';') ar = r; + if (prevc == 0) r.q0 = 0; // lhs defaults to 0 + if (a.i >= a.expr.len) { + r.q1 = clip(a.text.len); // rhs defaults to $ + } else { + if (a.depth >= max_depth) return null; + a.depth += 1; + const nr = a.address(ar) orelse return null; + a.depth -= 1; + r.q1 = nr.q1; + } + return r; + }, + '+', '-' => { + if (prevc == '+' or prevc == '-') { + const nc = if (a.i < a.expr.len) a.expr[a.i] else 0; + if (nc != '#' and nc != '/' and nc != '?') + r = a.number(r, 1, prevc, .line) orelse return null; + } + dir = c; + }, + '.', '$' => { + if (a.i != start + 1) { + a.i -= 1; + return r; + } + r = if (c == '.') ar else .{ .q0 = clip(a.text.len), .q1 = clip(a.text.len) }; + dir = if (a.i < a.expr.len) '+' else 0; + }, + '#', '0'...'9' => { + var digit = c; + if (c == '#') { + if (a.i >= a.expr.len or a.expr[a.i] < '0' or a.expr[a.i] > '9') { + a.i -= 1; + return r; + } + digit = a.expr[a.i]; + a.i += 1; + size = .char; + } + var n: u64 = digit - '0'; + while (a.i < a.expr.len) : (a.i += 1) { + const d = a.expr[a.i]; + if (d < '0' or d > '9') break; + n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); + } + r = a.number(r, @intCast(n), dir, size) orelse return null; + dir = 0; + size = .line; + }, + '/', '?' => { + const back = c == '?'; + r = a.regexp(r, a.pattern(c), back) orelse return null; + dir = 0; + size = .line; + }, + else => { + a.i -= 1; + return r; + }, + } + } + if (dir != 0) r = a.number(r, 1, dir, .line) orelse return null; + return r; + } + + fn pattern(a: *Addr, delim: u8) []const u8 { + const s = a.i; + while (a.i < a.expr.len) { + const c = a.expr[a.i]; + if (c == '\n') break; + a.i += 1; + if (c == '\\') { + if (a.i < a.expr.len) a.i += 1; + continue; + } + if (c == delim) return a.expr[s .. a.i - 1]; + } + return a.expr[s..a.i]; + } + + fn number(a: *Addr, r_in: PaneState.Range, n: u32, dir: u8, size: Size) ?PaneState.Range { + var r = r_in; + if (size == .char) { + var off: i64 = n; + if (dir == '+') { + off = @as(i64, r.q1) + n; + } else if (dir == '-') { + if (r.q0 == 0 and n > 0) r.q0 = clip(a.text.len); + off = @as(i64, r.q0) - n; + } + if (off < 0 or off > @as(i64, @intCast(a.text.len))) return null; + const g = clip(modal.graphemeStart(a.text, @intCast(off))); + return .{ .q0 = g, .q1 = g }; + } + var line: i64 = n; + var q0: usize = r.q0; + var q1: usize = r.q1; + switch (dir) { + '-' => { + if (q0 < a.text.len) while (q0 > 0 and a.text[q0 - 1] != '\n') { + q0 -= 1; + }; + q1 = q0; + while (line > 0 and q0 > 0) { + if (a.text[q0 - 1] == '\n') { + line -= 1; + q1 = q0; + } + q0 -= 1; + } + if (line > 1) return null; + while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + }, + '+' => { + if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') { + q1 += 1; + }; + q0 = q1; + }, + else => { + q0 = 0; + q1 = 0; + }, + } + while (line > 0 and q1 < a.text.len) { + const ch = a.text[q1]; + q1 += 1; + if (ch == '\n' or q1 == a.text.len) { + line -= 1; + if (line > 0) q0 = q1; + } + } + if (line > 0) return null; + return .{ .q0 = clip(q0), .q1 = clip(q1) }; + } + + fn regexp(a: *Addr, r: PaneState.Range, pat: []const u8, back: bool) ?PaneState.Range { + if (pat.len == 0 or !safePattern(pat)) return null; + const re = mvzr.compile(pat) orelse return null; + if (back) { + const hi = @min(@as(usize, r.q0), a.text.len); + var best: ?mvzr.Match = null; + var at: usize = 0; + while (at < hi) { + const m = re.matchPos(at, a.text[0..hi]) orelse break; + best = m; + at = if (m.end > m.start) m.end else m.end + 1; + } + const m = best orelse return null; + return .{ .q0 = clip(m.start), .q1 = clip(m.end) }; + } + const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len; + const from = @min(@as(usize, r.q1), hi); + const m = re.match(a.text[from..hi]) orelse return null; + return .{ .q0 = clip(from + m.start), .q1 = clip(from + m.end) }; + } +}; + +const Verb = enum { + @"addr=dot", + clean, + cleartag, + del, + delete, + dirty, + @"dot=addr", + get, + @"limit=addr", + mark, + nomark, + noscroll, + put, + scroll, + show, +}; + +const refused_verbs = [_][]const u8{ "dump", "dumpdir", "font", "lock", "menu", "nomenu", "unlock" }; + +fn verbIs(line: []const u8, word: []const u8) bool { + if (!std.mem.startsWith(u8, line, word)) return false; + return line.len == word.len or line[word.len] == ' '; +} + +fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { + for ([2]bool{ false, true }) |apply| { + var dirty = if (p.paneBySerial(serial)) |id| dirtyOf(p.panes[id].?) else false; + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); + const errno = ctlVerb(p, live, line, apply, &dirty); + if (errno != 0) return Reply.fail(req.tag, errno); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 { + const pane = p.panes[id] orelse return E.INVAL; + const pf = &p.fs.panes[id]; + + if (verbIs(line, "look")) { + if (line.len <= 5) return E.INVAL; + const word = std.mem.trim(u8, line[5..], " \t"); + if (word.len == 0) return E.INVAL; + for (word) |c| if (c < ' ') return E.INVAL; + if (apply) p.lookAt(id, word); + return 0; + } + if (verbIs(line, "name")) { + if (line.len <= 5) return E.INVAL; + const name = std.mem.trim(u8, line[5..], " \t"); + if (name.len == 0) return E.INVAL; + for (name) |c| if (c <= ' ') return E.INVAL; + const f = fileOf(pane) orelse return 0; + const full = std.fs.path.resolvePosix(p.gpa, &.{ Pardes.paneDir(pane), name }) catch return E.NOMEM; + defer p.gpa.free(full); + if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return E.INVAL; + if (std.mem.eql(u8, f.path, full)) return 0; + if (panes.Output.fileTraits(f.output).saves) dirty.* = true; + if (!apply) return 0; + const copy = p.gpa.dupe(u8, full) catch return E.NOMEM; + p.gpa.free(f.path); + f.path = copy; + if (panes.Output.fileTraits(f.output).saves) { + f.output = null; + pane.clearCwd(); + pane.tag_init = false; + pane.tag_tail_len = 0; + f.saved_revision = f.revision -% 1; + f.watch_after_save = localPath(full) != null; + } + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = true; + p.invalidateLookHover(id); + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + return 0; + } + for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL; + + const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; + if (v == .del and dirty.*) return E.INVAL; + switch (v) { + .dirty => dirty.* = true, + .clean, .get, .put => dirty.* = false, + else => {}, + } + if (!apply) return 0; + + switch (v) { + .@"addr=dot" => pf.addr = dotOf(pane), + .@"dot=addr" => { + clampAddr(pf, bodyOf(pane).len); + setDot(pane, pf.addr); + }, + .@"limit=addr" => { + clampAddr(pf, bodyOf(pane).len); + pf.limit = pf.addr; + }, + .clean => if (fileOf(pane)) |f| { + f.saved_revision = f.revision; + }, + .dirty => if (fileOf(pane)) |f| { + f.saved_revision = f.revision -% 1; + }, + .cleartag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .del, .delete => p.removePane(id) catch return E.NOMEM, + .put => { + const serial = pane.serial; + if (fileOf(pane)) |f| { + if (f.output == null) { + p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); + const current = p.paneBySerial(serial) orelse return E.NOENT; + if (dirtyOf(p.panes[current].?)) return E.IO; + } else _ = p.executeBuiltinLine(id, "Save"); + } else _ = p.executeBuiltinLine(id, "Save"); + }, + .get => if (fileOf(pane)) |f| { + if (panes.Output.fileTraits(f.output).saves) { + if (read(p, f.path)) |bytes| { + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; + } else |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, + }; + } + }, + .mark => { + pf.nomark = false; + panes.File.pushUndo(p, pane); + }, + .nomark => pf.nomark = true, + .noscroll => pf.noscroll = true, + .scroll => pf.noscroll = false, + .show => showOffset(pane, dotOf(pane).q0), + } + return 0; +} + +const PtyVerb = enum { winsize, sig, exec }; + +fn ptyDimension(word: []const u8) ?u16 { + if (word.len == 0 or word.len > 5) return null; + for (word) |c| if (c < '0' or c > '9') return null; + const n = std.fmt.parseInt(u16, word, 10) catch return null; + return if (n == 0) null else n; +} + +fn ptySignalNamed(word: []const u8) ?pardes.PtySignal { + if (std.mem.eql(u8, word, "INT")) return .int; + if (std.mem.eql(u8, word, "TERM")) return .term; + if (std.mem.eql(u8, word, "HUP")) return .hup; + if (std.mem.eql(u8, word, "QUIT")) return .quit; + if (std.mem.eql(u8, word, "KILL")) return .kill; + return null; +} + +fn writePtyCtl(p: *Pardes, req: Req, id: usize) Reply { + for ([2]bool{ false, true }) |apply| { + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!ptyVerb(p, id, line, apply)) return Reply.fail(req.tag, E.INVAL); + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn ptyVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { + const pane = p.panes[id] orelse return false; + var words = std.mem.tokenizeAny(u8, line, " \t"); + const v = std.meta.stringToEnum(PtyVerb, words.next() orelse return false) orelse return false; + switch (v) { + .winsize => { + const cols = ptyDimension(words.next() orelse return false) orelse return false; + const rows = ptyDimension(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + }, + .sig => { + const which = ptySignalNamed(words.next() orelse return false) orelse return false; + if (words.next() != null) return false; + if (!apply) return true; + p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } }); + }, + .exec => { + if (words.next() != null) return false; + if (pane.cwdSlice().len > pardes.effect_path_cap) return false; + if (!apply) return true; + p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } }); + }, + } + return true; +} + +fn writePtyData(p: *Pardes, req: Req, id: usize) Reply { + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + const take = wholeUtf8(req.data); + p.emitWrite(id, req.data[0..take]); + return .{ .tag = req.tag, .written = @intCast(take) }; +} + +const EventRecord = struct { action: Action, q0: u32, q1: u32 }; + +const EventReader = struct { + data: []const u8, + i: usize = 0, + + fn next(er: *EventReader) ?EventRecord { + if (er.i >= er.data.len) return null; + var i = er.i; + if (i + 2 > er.data.len) return null; + i += 1; + const action = Action.fromChar(er.data[i]) orelse return null; + i += 1; + const q0 = scanNumber(er.data, &i) orelse return null; + const q1 = scanNumber(er.data, &i) orelse return null; + while (i < er.data.len and er.data[i] == ' ') i += 1; + if (i >= er.data.len or er.data[i] != '\n') return null; + er.i = i + 1; + return .{ .action = action, .q0 = q0, .q1 = q1 }; + } +}; + +fn scanNumber(data: []const u8, i: *usize) ?u32 { + while (i.* < data.len and data[i.*] == ' ') i.* += 1; + const s = i.*; + var n: u64 = 0; + while (i.* < data.len and data[i.*] >= '0' and data[i.*] <= '9') : (i.* += 1) + n = @min(n * 10 + (data[i.*] - '0'), std.math.maxInt(u32)); + if (i.* == s) return null; + return @intCast(n); +} + +fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { + const pane0 = p.panes[id] orelse return Reply.fail(req.tag, E.NOENT); + const serial = pane0.serial; + { + const body = bodyOf(pane0); + const tag = tagOf(p, pane0); + var check: EventReader = .{ .data = req.data }; + while (check.next()) |r| { + switch (r.action) { + .body_look, .tag_look, .body_exec, .tag_exec => {}, + else => return Reply.fail(req.tag, E.INVAL), + } + const n = if (r.action.onTag()) tag.len else body.len; + if (r.q0 > r.q1 or r.q1 > n) return Reply.fail(req.tag, E.INVAL); + } + if (check.i != req.data.len) return Reply.fail(req.tag, E.INVAL); + } + var run: EventReader = .{ .data = req.data }; + while (run.next()) |r| { + const now = p.paneBySerial(serial) orelse break; + const pane = p.panes[now].?; + const whole = if (r.action.onTag()) tagOf(p, pane) else bodyOf(pane); + const lo = @min(@as(usize, r.q0), whole.len); + const hi = @max(lo, @min(@as(usize, r.q1), whole.len)); + const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue; + switch (r.action) { + .body_exec, .tag_exec => _ = p.execute(now, text), + .body_look, .tag_look => p.lookAt(now, text), + else => unreachable, + } + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { + if (text.len == 0) return 0; + const pane = p.panes[id] orelse return null; + const dir = dirOf(pane); + for (p.panes, 0..) |slot, i| { + const q = slot orelse continue; + const qf = fileOf(q) orelse continue; + const o = qf.output orelse continue; + if (std.meta.activeTag(o.from) != .errors) continue; + if (!std.mem.eql(u8, std.fs.path.dirname(qf.path) orelse "", dir)) continue; + return spliceBody(p, i, q, qf.content.len, qf.content.len, text); + } + const free = p.freeSlot() orelse return null; + const content = p.gpa.dupe(u8, text) catch return null; + const np = panes.Output.open(p, free, dir, .errors, "", content) catch { + p.gpa.free(content); + return null; + }; + p.placeDoc(id, free, np); + return text.len; +} + +fn indexLen(p: *Pardes) u64 { + var n: u64 = 0; + for (p.panes) |slot| { + const pane = slot orelse continue; + n += ctl_fields + firstLine(tagOf(p, pane)).len + 1; + } + return n; +} + +fn bodyLen(p: *Pardes, pane: *Pane) u64 { + _ = p; + return bodyOf(pane).len; +} + +fn tagLen(p: *Pardes, pane: *Pane) u64 { + return tagOf(p, pane).len; +} + +test "filesystem inspection preserves pending and displayed Look hover" { + const delay = config.look_preview_delay_frames orelse return; + const gpa = std.testing.allocator; + const p = try withFile(gpa, "alpha beta gamma\n"); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[0]; + p.update(.{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = rect.x + config.GUTTER + config.PREFIX_W + 7, + .row = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + } }); + try testing.expect(p.look_hover_wait != null); + const body = Node.of(serialOf(p), .body); + const screen = @intFromEnum(SelfFile.screen); + for (0..2) |phase| { + if (phase == 1) { + for (0..delay) |_| p.update(.tick); + try testing.expect(p.look_hover_preview != null); + } + const waiting = p.look_hover_wait; + const preview = p.look_hover_preview; + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" }, + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." }, + .{ .tag = 2, .op = .getattr, .node = body }, + .{ .tag = 3, .op = .open, .node = body }, + .{ .tag = 4, .op = .read, .node = body, .size = 5 }, + .{ .tag = 5, .op = .release, .node = body, .handle = 1 }, + .{ .tag = 6, .op = .readdir, .node = @intFromEnum(SelfFile.root), .size = 4096 }, + .{ .tag = 7, .op = .open, .node = screen }, + .{ .tag = 8, .op = .read, .node = screen, .handle = 1, .size = 32 }, + .{ .tag = 9, .op = .release, .node = screen, .handle = 1 }, + }; + for (requests) |req| { + const answer = call(p, req); + try testing.expectEqual(Status.ok, answer.reply.status); + try testing.expect(std.meta.eql(waiting, p.look_hover_wait)); + try testing.expect(std.meta.eql(preview, p.look_hover_preview)); + try testing.expect(p.raw_hover_intent); + } + } + try testing.expectEqual(Status.ok, wr(p, body, "changed").reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); +} + +test "filesystem pane creation and truncation cancel Look hover" { + const requests = [_]Req{ + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, + .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }, + .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, + }; + for (requests) |request| { + const p = try withFile(testing.allocator, "word\n"); + defer p.deinit(); + p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = serialOf(p) }; + p.raw_hover_intent = true; + var req = request; + if (req.op == .setattr) req.node = Node.of(serialOf(p), .body); + try testing.expectEqual(Status.ok, call(p, req).reply.status); + try testing.expect(p.look_hover_wait == null); + try testing.expect(p.look_hover_preview == null); + try testing.expect(!p.raw_hover_intent); + } +} + +test "terminal body handles keep one history snapshot across fragmented reads" { + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + 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); + defer gpa.free(original); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const first = call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 3 }); + try testing.expectEqualStrings(original[0..3], first.bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3J\x1b[2J\x1b[Hnew output" } }); + while (p.nextEffect()) |_| {} + var offset: usize = 3; + while (offset < original.len) { + const part = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = offset, .size = 3 }); + try testing.expectEqualStrings(original[offset..][0..@min(3, original.len - offset)], part.bytes); + offset += part.bytes.len; + } + try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes); + _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 6, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "an empty terminal body snapshot stays empty while output continues" { + const p = try withTerm(std.testing.allocator); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + try testing.expect(p.fs.snapshots[opened.reply.handle - 1].bytes != null); + p.update(.{ .output = .{ .pane = 0, .bytes = "new output" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); + const newer = call(p, .{ .tag = 5, .op = .open, .node = node }); + try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes); + _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle }); +} + +test "terminal body snapshots are lazy bounded and released after the pane closes" { + const gpa = std.testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .body); + var handles: [32]u32 = undefined; + for (&handles) |*opened_handle| { + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + opened_handle.* = opened.reply.handle; + } + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(node, snapshot.node); + try testing.expect(snapshot.bytes == null); + } + try testing.expectEqual(E.NFILE, call(p, .{ .tag = 2, .op = .open, .node = node }).errno()); + const scratch = call(p, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); + try testing.expectEqual(Status.ok, scratch.reply.status); + const scratch_id = p.paneBySerial(Node.target(scratch.reply.attr.node).?.pane.serial).?; + try testing.expect(p.panes[scratch_id].?.file != null); + _ = call(p, .{ .tag = 2, .op = .release, .node = scratch.reply.attr.node, .handle = scratch.reply.handle }); + p.update(.{ .output = .{ .pane = 0, .bytes = "latest output" } }); + while (p.nextEffect()) |_| {} + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const failed = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + p.gpa = gpa; + try testing.expectEqual(E.NOMEM, failed.errno()); + try testing.expect(p.fs.snapshots[handles[0] - 1].bytes == null); + const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 }); + try testing.expectEqualStrings("latest output", first.bytes); + const saved = p.fs.snapshots[handles[0] - 1].bytes.?; + failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.gpa = failing.allocator(); + const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 }); + p.gpa = gpa; + try testing.expectEqualStrings("output", second.bytes); + try testing.expect(!failing.has_induced_failure); + try testing.expectEqual(saved.ptr, p.fs.snapshots[handles[0] - 1].bytes.?.ptr); + const screen = @intFromEnum(SelfFile.screen); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen, .handle = handles[0], .size = 32 }).errno()); + _ = call(p, .{ .tag = 7, .op = .release, .node = screen, .handle = handles[0] }); + try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node); + try p.removePane(0); + for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle }); + for (p.fs.snapshots) |snapshot| { + try testing.expectEqual(@as(u64, 0), snapshot.node); + try testing.expect(snapshot.bytes == null); + } +} + +test "screen snapshots preserve rendered cells and styles until their handle is released" { + const gpa = std.testing.allocator; + const p = try withFile(gpa, "const value = 1;\n"); + defer p.deinit(); + const node = @intFromEnum(SelfFile.screen); + const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const snapshot = p.fs.snapshots[opened.reply.handle - 1].bytes.?; + const original = try gpa.dupe(u8, snapshot); + defer gpa.free(original); + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, original, .{}); + defer parsed.deinit(); + const data = parsed.value.object; + try testing.expectEqual(@as(i64, p.screen_w), data.get("cols").?.integer); + try testing.expectEqual(@as(i64, p.screen_h), data.get("rows").?.integer); + try testing.expectEqual(@as(usize, p.screen_w) * p.screen_h, data.get("cells").?.array.items.len); + try testing.expect(data.get("styles").?.array.items.len > 0); + _ = wr(p, Node.of(serialOf(p), .body), "changed\n"); + const newer = call(p, .{ .tag = 2, .op = .open, .node = node }); + try testing.expectEqual(Status.ok, newer.reply.status); + try testing.expect(!std.mem.eql(u8, original, p.fs.snapshots[newer.reply.handle - 1].bytes.?)); + var off: usize = 0; + while (off < original.len) { + const result = call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .off = off, .size = 13 }); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqualSlices(u8, original[off..][0..@min(13, original.len - off)], result.bytes); + off += result.bytes.len; + } + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle }); + _ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = newer.reply.handle }); + for (p.fs.snapshots) |slot| try testing.expect(slot.node == 0); +} + +test "screen inspection preserves acknowledged presentation and the next real frame" { + const gpa = testing.allocator; + for (std.enums.values(pardes.layout.Transition)) |transition| { + errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); + const control = try withFile(gpa, "const value = 1;\n"); + defer control.deinit(); + const inspected = try withFile(gpa, "const value = 1;\n"); + defer inspected.deinit(); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + for ([_]*Pardes{ control, inspected }) |p| { + p.settings.panel_transition = .off; + p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.settings.panel_transition = transition; + } + for (0..4) |phase| { + for ([_]*Pardes{ control, inspected }) |p| switch (phase) { + 0 => {}, + 1 => p.update(.{ .command = "New" }), + 2 => { + p.update(.tick); + try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .body), "changed cells\n").reply.status); + }, + 3 => { + for (0..transition.frames() + 1) |_| p.update(.tick); + p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); + p.update(.{ .command = "Del" }); + }, + else => unreachable, + }; + const screen = @intFromEnum(SelfFile.screen); + for (0..2) |_| { + const opened = call(inspected, .{ .tag = 1, .op = .open, .node = screen }); + try testing.expectEqual(Status.ok, opened.reply.status); + const captured = call(inspected, .{ .tag = 2, .op = .read, .node = screen, .handle = opened.reply.handle, .size = 32 }); + try testing.expectEqual(Status.ok, captured.reply.status); + try testing.expect(captured.bytes.len > 0); + try testing.expectEqual(Status.ok, call(inspected, .{ + .tag = 3, + .op = .release, + .node = screen, + .handle = opened.reply.handle, + }).reply.status); + } + const a = &control.presentation; + const b = &inspected.presentation; + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.shown_tracks, b.shown_tracks); + try testing.expectEqualDeep(a.shown_closing[0..a.shown_closing_len], b.shown_closing[0..b.shown_closing_len]); + try testing.expectEqual(a.pending, b.pending); + try testing.expectEqual(a.acknowledged, b.acknowledged); + try testing.expectEqual(a.previous_valid, b.previous_valid); + try testing.expectEqual(a.previous_cols, b.previous_cols); + try testing.expectEqual(a.previous_rows, b.previous_rows); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + try testing.expectEqual(a.previous_cells.len, b.previous_cells.len); + for (a.previous_cells, b.previous_cells) |*expected, *actual| + try testing.expect(expected.visuallyEqual(actual)); + + const expected = try control.render(arena.allocator()); + const actual = try inspected.render(arena.allocator()); + try testing.expectEqual(expected.cols, actual.cols); + try testing.expectEqual(expected.rows, actual.rows); + try testing.expectEqualDeep(expected.cursor, actual.cursor); + try testing.expectEqualDeep(expected.panelTracks(), actual.panelTracks()); + try testing.expectEqualDeep(expected.cell_diffs, actual.cell_diffs); + try testing.expectEqual(expected.previous_cells.len, actual.previous_cells.len); + try testing.expectEqual(expected.cells.len, actual.cells.len); + try testing.expectEqual(@as(usize, 0), expected.nimages); + try testing.expectEqual(@as(usize, 0), actual.nimages); + for (expected.cells, actual.cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + for (expected.previous_cells, actual.previous_cells) |*left, *right| try testing.expect(left.visuallyEqual(right)); + if (phase == 1 and transition != .off) try testing.expect(expected.panelTracks().len > 0); + if (phase == 1 and transition.needsPreviousGrid()) try testing.expect(expected.hasPanelDiff()); + if (phase == 3 and transition == .vertical) try testing.expect(a.closing_len > 0); + control.acknowledgePanelPresentation(expected.panelTracks()); + inspected.acknowledgePanelPresentation(actual.panelTracks()); + try testing.expectEqualDeep(a.shown, b.shown); + try testing.expectEqualDeep(a.previous_layout, b.previous_layout); + _ = arena.reset(.retain_capacity); + } + } +} + +const testing = std.testing; + +const Answer = struct { + reply: Reply = .{ .tag = 0, .status = .err, .errno = E.IO }, + bytes: []const u8 = "", + saved: bool = false, + watch: ?bool = null, + pty_buf: [256]u8 = undefined, + pty_len: usize = 0, + winsize: ?struct { cols: u16, rows: u16 } = null, + signal: ?pardes.PtySignal = null, + spawned: bool = false, + + fn pty(a: *const Answer) []const u8 { + return a.pty_buf[0..a.pty_len]; + } + + fn errno(a: Answer) u16 { + return if (a.reply.status == .err) a.reply.errno else 0; + } +}; + +fn call(p: *Pardes, req: Req) Answer { + p.update(.{ .fs_req = req }); + var ans: Answer = .{}; + while (p.nextEffect()) |e| switch (e) { + .fs_reply => |r| { + ans.reply = r; + ans.bytes = p.fsPayload(r); + }, + .save_file, .save_text => ans.saved = true, + .watch => |w| ans.watch = w.on, + .write => |w| { + const b = w.bytes.slice(); + const n = @min(b.len, ans.pty_buf.len - ans.pty_len); + @memcpy(ans.pty_buf[ans.pty_len..][0..n], b[0..n]); + ans.pty_len += n; + }, + .resize_pty => |r| ans.winsize = .{ .cols = r.cols, .rows = r.rows }, + .signal_pty => |s| ans.signal = s.sig, + .spawn => ans.spawned = true, + else => {}, + }; + return ans; +} + +fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { + return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); +} + +fn wr(p: *Pardes, node: u64, data: []const u8) Answer { + return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); +} + +fn rdir(p: *Pardes, node: u64, skip: u64) Answer { + return call(p, .{ .tag = 4, .op = .readdir, .node = node, .off = skip, .size = 4096 }); +} + +fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { + return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); +} + +fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile(text); + while (p.nextEffect()) |_| {} + return p; +} + +fn withTerm(gpa: std.mem.Allocator) !*Pardes { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + errdefer p.deinit(); + while (p.nextEffect()) |_| {} + std.debug.assert(p.panes[0].?.isTerminal()); + return p; +} + +fn serialOf(p: *Pardes) u32 { + return p.panes[0].?.serial; +} + +const Dirent = struct { node: u64, dir: bool, name: []const u8 }; + +fn dirents(bytes: []const u8, out: []Dirent) []Dirent { + var n: usize = 0; + var i: usize = 0; + while (i + 10 <= bytes.len and n < out.len) { + const node = std.mem.readInt(u64, bytes[i..][0..8], .little); + const kind = bytes[i + 8]; + const len = bytes[i + 9]; + i += 10; + if (i + len > bytes.len) break; + out[n] = .{ .node = node, .dir = kind == 1, .name = bytes[i .. i + len] }; + i += len; + n += 1; + } + return out[0..n]; +} + +fn nameAt(list: []const Dirent, want: []const u8) ?Dirent { + for (list) |d| if (std.mem.eql(u8, d.name, want)) return d; + return null; +} + +test "listener addresses are readable canonical dials with bounded partial reads" { + const gpa = testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/a socket.sock"; + p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; + p.fs.quic_address = .{ .ip6 = .loopback(5641) }; + const expected = "unix!/tmp/a socket.sock\ntcp!127.0.0.1!5640\nquic!::1!5641\n"; + for ([_][]const u8{ "/virtual/listeners", "/n/self/listeners" }) |path| { + const bytes = try read(p, path); + defer gpa.free(bytes); + try testing.expectEqualStrings(expected, bytes); + try testing.expectError(error.ReadOnlyFilesystem, write(p, path, "")); + } + const node = @intFromEnum(SelfFile.listeners); + var off: usize = 0; + while (off < expected.len) { + const part = rd(p, node, off, 3); + try testing.expectEqual(Status.ok, part.reply.status); + try testing.expectEqualStrings(expected[off..][0..@min(3, expected.len - off)], part.bytes); + off += part.bytes.len; + } + try testing.expectEqual(@as(usize, 0), rd(p, node, off, 3).bytes.len); +} + +test "readdir lists the root, a pane directory, and new/ without creating anything" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + var buf: [32]Dirent = undefined; + + const root = rdir(p, @intFromEnum(SelfFile.root), 0); + try testing.expectEqual(Status.ok, root.reply.status); + const top = dirents(root.bytes, &buf); + try testing.expect(top.len >= 5); + try testing.expectEqualStrings("index", top[0].name); + try testing.expectEqualStrings("cons", top[1].name); + try testing.expectEqualStrings("new", top[2].name); + try testing.expect(top[2].dir and !top[0].dir); + try testing.expectEqualStrings("pane", top[3].name); + try testing.expect(top[3].dir); + try testing.expectEqual(namespace_panes, top[3].node); + var idbuf: [16]u8 = undefined; + try testing.expect(nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); + try testing.expect(nameAt(top, "src") != null); + + const rest = rdir(p, @intFromEnum(SelfFile.root), 3); + try testing.expectEqual(top.len - 3, dirents(rest.bytes, &buf).len); + const eof = rdir(p, @intFromEnum(SelfFile.root), 99); + try testing.expectEqual(Status.ok, eof.reply.status); + try testing.expectEqual(@as(usize, 0), eof.bytes.len); + + const dir = rdir(p, Node.of(serial, .dir), 0); + const files = dirents(dir.bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(nameAt(files, "addr") != null); + try testing.expect(nameAt(files, "xdata") != null); + try testing.expect(nameAt(files, ".") == null); + try testing.expectEqual(Node.of(serial, .body), nameAt(files, "body").?.node); + + const before = p.next_serial; + const new = rdir(p, @intFromEnum(SelfFile.new), 0); + try testing.expectEqual(Status.ok, new.reply.status); + try testing.expectEqual(@as(usize, 0), new.bytes.len); + try testing.expectEqual(before, p.next_serial); + + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); +} + +test "lookup resolves top files, pane serials and pane files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const root = @intFromEnum(SelfFile.root); + + try testing.expectEqual(@as(u64, @intFromEnum(SelfFile.index)), look_up(p, root, "index").reply.attr.node); + try testing.expect(look_up(p, root, "new").reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); + + var idbuf: [16]u8 = undefined; + const serial_name = try std.fmt.bufPrint(&idbuf, "{d}", .{serial}); + try testing.expectEqual(E.NOENT, look_up(p, root, serial_name).errno()); + const dir = look_up(p, namespace_panes, serial_name); + try testing.expectEqual(Node.of(serial, .dir), dir.reply.attr.node); + try testing.expect(dir.reply.attr.dir); + try testing.expectEqual(E.NOENT, look_up(p, namespace_panes, "99999").errno()); + + const body = look_up(p, Node.of(serial, .dir), "body"); + try testing.expectEqual(Node.of(serial, .body), body.reply.attr.node); + const stat = call(p, .{ .tag = 4, .op = .getattr, .node = Node.of(serial, .body) }); + try testing.expectEqual(body.reply.attr.size, stat.reply.attr.size); + try testing.expectEqual(@as(u64, "hello\n".len), stat.reply.attr.size); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .dir), "editout").errno()); + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); +} + +test "a lookup inside new/ creates a pane and resolves that pane's file" { + const gpa = testing.allocator; + const p = try withFile(gpa, "first\n"); + defer p.deinit(); + const before = serialOf(p); + + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "bogus").errno()); + try testing.expectEqual(before, p.next_serial); + + const a = look_up(p, @intFromEnum(SelfFile.new), "body"); + try testing.expectEqual(Status.ok, a.reply.status); + const made: Node = @bitCast(a.reply.attr.node); + try testing.expect(made.serial != before); + try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); + + _ = wr(p, a.reply.attr.node, "hi"); + const id = p.paneBySerial(@intCast(made.serial)).?; + try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); +} + +test "index prints winctlprint's five fields then the tag" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\nthere\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, @intFromEnum(SelfFile.index), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var got: [512]u8 = undefined; + @memcpy(got[0..a.bytes.len], a.bytes); + const line = got[0..a.bytes.len]; + + const tag = tagOf(p, pane); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ + pane.serial, tag.len, @as(usize, "hello\nthere\n".len), 0, 0, firstLine(tag), + }); + try testing.expectEqualStrings(want.items, line); + try testing.expectEqual(@as(usize, 60), std.mem.indexOf(u8, line, firstLine(tag)).?); + + const mid = rd(p, @intFromEnum(SelfFile.index), 60, 5); + try testing.expectEqualStrings(firstLine(tag)[0..5], mid.bytes); + + pane.file.?.saved_revision = pane.file.?.revision -% 1; + const dirty = rd(p, @intFromEnum(SelfFile.index), 48, 12); + try testing.expectEqualStrings(" 1 ", dirty.bytes); +} + +test "ctl read is index's five fields plus width in cells, font and tab width" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + + const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096); + try testing.expectEqual(Status.ok, a.reply.status); + var want: std.ArrayList(u8) = .empty; + defer want.deinit(gpa); + try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} ", .{ + pane.serial, tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, + }); + try testing.expectEqualStrings(want.items, a.bytes); + + var quoted: std.ArrayList(u8) = .empty; + defer quoted.deinit(gpa); + stageQuoted("ed, gpa, "DejaVu Sans Mono"); + try testing.expectEqualStrings("'DejaVu Sans Mono'", quoted.items); + quoted.clearRetainingCapacity(); + stageQuoted("ed, gpa, "it's"); + try testing.expectEqualStrings("'it''s'", quoted.items); +} + +test "body reads at any offset and writes append" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + + try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 100).bytes); + try testing.expectEqualStrings("two\n", rd(p, body, 4, 100).bytes); + try testing.expectEqualStrings("wo", rd(p, body, 5, 2).bytes); + try testing.expectEqualStrings("", rd(p, body, 999, 2).bytes); + try testing.expect(rd(p, body, 0, 100).bytes.ptr == p.panes[0].?.file.?.content.ptr); + + const w = call(p, .{ .tag = 5, .op = .write, .node = body, .off = 0, .data = "three\n" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("one\ntwo\nthree\n", p.panes[0].?.file.?.content); + + const short = wr(p, body, "a\xC3"); + try testing.expectEqual(@as(u32, 1), short.reply.written); + try testing.expectEqualStrings("one\ntwo\nthree\na", p.panes[0].?.file.?.content); +} + +test "a body write to a terminal pane types at its shell" { + const gpa = testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.isTerminal()); + + const a = wr(p, Node.of(pane.serial, .body), "ls -l\r"); + try testing.expectEqual(@as(u32, 6), a.reply.written); + try testing.expectEqualStrings("ls -l\r", a.pty()); + + const r = rd(p, Node.of(pane.serial, .body), 0, 64); + try testing.expectEqual(Status.ok, r.reply.status); +} + +test "tag reads the whole tag and writes append to the editable tail" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + const node = Node.of(pane.serial, .tag); + + const whole = rd(p, node, 0, 4096); + try testing.expect(std.mem.startsWith(u8, whole.bytes, "/test.txt")); + try testing.expect(std.mem.indexOf(u8, whole.bytes, "Del") != null); + + const before = rd(p, node, 0, 4096).bytes.len; + const w = wr(p, node, " Mine"); + try testing.expectEqual(@as(u32, 5), w.reply.written); + try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine")); + const after = rd(p, node, 0, 4096); + try testing.expectEqual(before + 5, after.bytes.len); + try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine")); + + pane.tag_tail_len = pane.tag_tail.len; + try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno()); +} + +test "the address language, form by form" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\nthree\n"); + defer p.deinit(); + const serial = serialOf(p); + const addr = Node.of(serial, .addr); + + const Case = struct { expr: []const u8, q0: u32, q1: u32 }; + for ([_]Case{ + .{ .expr = "#0", .q0 = 0, .q1 = 0 }, + .{ .expr = "#5", .q0 = 5, .q1 = 5 }, + .{ .expr = "0", .q0 = 0, .q1 = 0 }, + .{ .expr = "1", .q0 = 0, .q1 = 4 }, + .{ .expr = "2", .q0 = 4, .q1 = 8 }, + .{ .expr = "$", .q0 = 14, .q1 = 14 }, + .{ .expr = ",", .q0 = 0, .q1 = 14 }, + .{ .expr = "1,2", .q0 = 0, .q1 = 8 }, + .{ .expr = "#1,#4", .q0 = 1, .q1 = 4 }, + .{ .expr = "2+1", .q0 = 8, .q1 = 14 }, + .{ .expr = "$-1", .q0 = 8, .q1 = 14 }, + .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, + .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, + .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, + }) |c| { + _ = wr(p, addr, "#0"); + const w = wr(p, addr, c.expr); + try testing.expectEqual(Status.ok, w.reply.status); + const got = rd(p, addr, 0, 64); + var want: [32]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} ", .{ c.q0, c.q1 }), + got.bytes, + ); + } + + _ = wr(p, addr, "1"); + _ = wr(p, addr, "."); + try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "$"); + _ = wr(p, addr, "?o?"); + try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" + try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "1"); + _ = wr(p, Node.of(serial, .ctl), "limit=addr\n"); + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, "/three/").errno()); + _ = wr(p, Node.of(serial, .ctl), "clean\n"); + _ = call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); + try testing.expect(p.fs.panes[0].limit == null); + _ = wr(p, addr, "#0"); + try testing.expectEqual(Status.ok, wr(p, addr, "/three/").reply.status); + + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, bad).errno()); + } + + const nested = "," ** 4096; + _ = wr(p, addr, "#0"); + try testing.expectEqual(E.INVAL, wr(p, addr, nested).errno()); +} + +test "data and xdata read from addr, move it, and write through it" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const addr = Node.of(serial, .addr); + const data = Node.of(serial, .data); + const xdata = Node.of(serial, .xdata); + + _ = wr(p, addr, "#0"); + try testing.expectEqualStrings("one", rd(p, data, 0, 3).bytes); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + + _ = wr(p, addr, "1"); + try testing.expectEqualStrings("one\n", rd(p, xdata, 0, 100).bytes); + _ = wr(p, addr, "1"); + try testing.expectEqualStrings("one\ntwo\n", rd(p, data, 0, 100).bytes); + + _ = wr(p, addr, "1"); + const w = wr(p, data, "ONE\n"); + try testing.expectEqual(@as(u32, 4), w.reply.written); + try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].addr.q0); +} + +test "data never splits a grapheme, in either direction" { + const gpa = testing.allocator; + const p = try withFile(gpa, "\u{00e9}x\n"); + defer p.deinit(); + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqualStrings("", rd(p, Node.of(serial, .data), 0, 1).bytes); + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqualStrings("\u{00e9}", rd(p, Node.of(serial, .data), 0, 2).bytes); + + _ = wr(p, Node.of(serial, .addr), "#0"); + try testing.expectEqual(@as(u32, 1), wr(p, Node.of(serial, .data), "a\xC3").reply.written); +} + +test "rdsel reads the selection and wrsel replaces it" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + + _ = wr(p, Node.of(serial, .addr), "#0,#3"); + try testing.expectEqual(Status.ok, wr(p, ctl, "dot=addr\n").reply.status); + try testing.expectEqualStrings("one", rd(p, Node.of(serial, .rdsel), 0, 100).bytes); + + _ = wr(p, ctl, "addr=dot\n"); + try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); + try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .wrsel), "ONE").reply.status); + try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); + _ = wr(p, Node.of(serial, .wrsel), "!"); + try testing.expectEqualStrings("ONE!\ntwo\n", p.panes[0].?.file.?.content); +} + +test "every ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + + try testing.expectEqual(Status.ok, wr(p, ctl, "nomark\nnoscroll\ndirty\n").reply.status); + try testing.expect(pf.nomark and pf.noscroll and dirtyOf(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl, "mark\nscroll\nclean\n").reply.status); + try testing.expect(!pf.nomark and !pf.noscroll and !dirtyOf(pane)); + + _ = wr(p, ctl, "cleartag\n"); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + + _ = wr(p, Node.of(serial, .addr), "2"); + _ = wr(p, ctl, "limit=addr\n"); + try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); + _ = wr(p, ctl, "dot=addr\nshow\n"); + try testing.expectEqual(@as(i32, 1), pane.cur_row); + + try testing.expectEqual(Status.ok, wr(p, ctl, "name /tmp/renamed.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); + try testing.expectEqual(E.INVAL, wr(p, ctl, "name two words\n").errno()); + try testing.expectEqual(E.INVAL, wr(p, ctl, "name\n").errno()); + try testing.expectEqualStrings("/tmp/renamed.txt", pane.file.?.path); + + try testing.expectEqual(Status.ok, wr(p, ctl, "put\n").reply.status); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); + + for ([_][]const u8{ + "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", + }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl, bad).errno()); + + try testing.expect(!dirtyOf(pane)); + try testing.expectEqual(E.INVAL, wr(p, ctl, "dirty\nbogus\n").errno()); + try testing.expect(!dirtyOf(pane)); +} + +test "ctl look opens spaced paths and locations without editing the source pane" { + if (!platform_has_fs) return error.SkipZigTest; + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" }); + var path_buf: [4096]u8 = undefined; + const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)]; + const p = try withFile(gpa, "source stays intact\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl = Node.of(source.serial, .ctl); + const revision = source.file.?.revision; + const undo_len = source.file.?.history.undo_len; + source.cur_col = 3; + var command: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + const opened = p.panes[p.active].?; + try testing.expect(opened != source); + try testing.expectEqualStrings(path, opened.file.?.path); + try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content); + try testing.expectEqual(@as(i32, 1), opened.cur_row); + try testing.expectEqual(@as(i32, 2), opened.cur_col); + try testing.expectEqualStrings("source stays intact\n", source.file.?.content); + try testing.expectEqual(revision, source.file.?.revision); + try testing.expectEqual(undo_len, source.file.?.history.undo_len); + try testing.expectEqual(@as(i32, 3), source.cur_col); + + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expect(p.panes[p.active].? == opened); + try testing.expectEqual(@as(i32, 2), opened.cur_row); + try testing.expectEqual(@as(i32, 1), opened.cur_col); +} + +test "ctl look validates the whole batch before opening virtual files" { + const gpa = testing.allocator; + const p = try withFile(gpa, "source\n"); + defer p.deinit(); + const source = p.panes[0].?; + const ctl = Node.of(source.serial, .ctl); + const serial = p.next_serial; + for ([_][]const u8{ + "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, ctl, invalid).errno()); + try testing.expectEqual(serial, p.next_serial); + try testing.expectEqual(@as(usize, 0), p.active); + try testing.expectEqualStrings("source\n", source.file.?.content); + } + try testing.expectEqual(Status.ok, wr(p, ctl, "look /n/self/index\n").reply.status); + const opened = p.panes[p.active].?; + try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); + try testing.expectEqualStrings("source\n", source.file.?.content); +} + +test "ctl name promotes a scratch without changing its body or undo history" { + const gpa = testing.allocator; + const p = try withFile(gpa, "opener\n"); + defer p.deinit(); + _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + const pane = p.panes[p.active].?; + const ctl = Node.of(pane.serial, .ctl); + const body = Node.of(pane.serial, .body); + _ = wr(p, body, "scratch "); + _ = wr(p, body, "work\n"); + const undo_len = pane.file.?.history.undo_len; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "renamed.zig", .data = "existing target\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buffer: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "{s}/renamed.zig", .{directory}); + var command: [4104]u8 = undefined; + const renamed = wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}\n", .{path})); + try testing.expectEqual(Status.ok, renamed.reply.status); + try testing.expectEqual(false, renamed.watch.?); + try testing.expectEqualStrings("scratch work\n", pane.file.?.content); + try testing.expectEqual(undo_len, pane.file.?.history.undo_len); + try testing.expect(pane.file.?.output == null and dirtyOf(pane)); + try testing.expect(pane.file.?.watch_after_save); + const target = try readFile(gpa, path); + defer gpa.free(target); + try testing.expectEqualStrings("existing target\n", target); + const saved = wr(p, ctl, "put\n"); + try testing.expectEqual(Status.ok, saved.reply.status); + try testing.expectEqual(true, saved.watch.?); + try testing.expect(!dirtyOf(pane) and !pane.file.?.watch_after_save); + try testing.expectEqualStrings("scratch work\n", p.fallback.get(path).?); +} + +test "ctl name refreshes cached syntax for unchanged contents" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + pardes.syntax.start(testing.allocator); + defer pardes.syntax.stop(); + const p = try withFile(testing.allocator, "fn check() void {}\n"); + defer p.deinit(); + const pane = p.panes[0].?; + panes.File.refreshHighlights(p); + try testing.expectEqual(@as(usize, 0), pane.file.?.highlights.len); + try testing.expectEqual(Status.ok, wr(p, Node.of(pane.serial, .ctl), "name renamed.zig\n").reply.status); + panes.File.refreshHighlights(p); + try testing.expect(pane.file.?.highlights.len >= 2); + try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[0]); + try testing.expectEqual(@intFromEnum(pardes.syntax.Syn.keyword), pane.file.?.highlights[1]); + try testing.expectEqualStrings("fn check() void {}\n", pane.file.?.content); +} + +test "ctl relative names use the file directory and can name a new target" { + const gpa = testing.allocator; + const p = try withFile(gpa, "retained body\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from pane directory\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .ctl); + var command: [4140]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name {s}/old.txt\n", .{directory})).reply.status); + try testing.expectEqual(Status.ok, wr(p, ctl, "name child/../note.txt\nget\n").reply.status); + try testing.expectEqualStrings("from pane directory\n", pane.file.?.content); + try testing.expectEqualStrings(directory, Pardes.paneDir(pane)); + try testing.expectEqual(Status.ok, wr(p, ctl, "name ./created.txt\nput\n").reply.status); + try testing.expect(!dirtyOf(pane)); + var expected_buf: [4096]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory}); + try testing.expectEqualStrings(expected, pane.file.?.path); + try testing.expectEqualStrings(pane.file.?.content, p.fallback.get(expected).?); +} + +test "ctl relative names follow inherited scratch and virtual directories" { + const p = try withFile(testing.allocator, "source body\n"); + defer p.deinit(); + const source = p.panes[0].?; + try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status); + _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + const scratch = p.panes[p.active].?; + const ctl = Node.of(scratch.serial, .ctl); + try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); + try testing.expectEqual(Status.ok, wr(p, ctl, "name ../out/./notes.txt\n").reply.status); + try testing.expectEqualStrings("/project/out/notes.txt", scratch.file.?.path); + try testing.expectEqualStrings("/project/out", Pardes.paneDir(scratch)); + var command: [256]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&command, "name /virtual/pane/{d}/./body\nget\n", .{source.serial})).reply.status); + try testing.expectEqualStrings(source.file.?.content, scratch.file.?.content); + const unchanged = wr(p, ctl, "name ./body\n"); + try testing.expectEqual(Status.ok, unchanged.reply.status); + try testing.expect(unchanged.watch == null); + try testing.expect(!dirtyOf(scratch)); + try testing.expect(!scratch.file.?.watch_after_save); +} + +test "ctl get reloads the pane from disk and del honours a dirty body" { + const gpa = testing.allocator; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory}); + + const p = try withFile(gpa, "in memory\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + const pane = p.panes[0].?; + + var name: [std.fs.max_path_bytes + 8]u8 = undefined; + _ = wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}\n", .{path})); + try testing.expectEqual(Status.ok, wr(p, ctl, "get\n").reply.status); + try testing.expectEqualStrings("from disk\n", pane.file.?.content); + try testing.expect(!dirtyOf(pane)); + try testing.expect(pane.file.?.history.undo_len > 0); + + _ = wr(p, ctl, "dirty\n"); + try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); + try testing.expect(p.paneBySerial(serial) != null); + _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); + try testing.expect(p.paneBySerial(serial) == null); +} + +test "ctl get reports missing files without losing dirty contents or applying del" { + const p = try withFile(testing.allocator, "unsaved contents\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)]; + var name: [4140]u8 = undefined; + const ctl = Node.of(serialOf(p), .ctl); + try testing.expectEqual(Status.ok, wr(p, ctl, try std.fmt.bufPrint(&name, "name {s}/missing.txt\ndirty\n", .{directory})).reply.status); + try testing.expectEqual(E.NOENT, wr(p, ctl, "get\ndel\n").errno()); + const pane = p.panes[0].?; + try testing.expectEqualStrings("unsaved contents\n", pane.file.?.content); + try testing.expect(dirtyOf(pane)); +} + +test "ctl put waits for one host write and stops before del when saving fails" { + const Refusing = struct { + p: *Pardes, + calls: usize = 0, + + fn writeFile(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.calls += 1; + self.p.saveFailed(id, "save", error.PermissionDenied); + } + }; + const p = try withFile(testing.allocator, "retained contents\n"); + defer p.deinit(); + var refusing: Refusing = .{ .p = p }; + p.host = .{ .ctx = &refusing, .vtable = &.{ .write_file = Refusing.writeFile } }; + const serial = serialOf(p); + const ctl = Node.of(serial, .ctl); + for ([_][]const u8{ "clean\n", "dirty\n" }, 0..) |initial, i| { + _ = wr(p, ctl, initial); + const result = wr(p, ctl, "put\ndel\n"); + try testing.expectEqual(E.IO, result.errno()); + try testing.expect(!result.saved); + try testing.expectEqual(i + 1, refusing.calls); + const pane = p.panes[p.paneBySerial(serial).?].?; + try testing.expect(dirtyOf(pane)); + try testing.expectEqualStrings("retained contents\n", pane.file.?.content); + } +} + +test "self directories walk to their namespace parents" { + const p = try withFile(testing.allocator, "contents\n"); + defer p.deinit(); + const root = @intFromEnum(SelfFile.root); + for ([_]struct { node: u64, parent: u64 }{ + .{ .node = root, .parent = namespace_root }, + .{ .node = @intFromEnum(SelfFile.new), .parent = root }, + .{ .node = namespace_panes, .parent = root }, + .{ .node = Node.of(serialOf(p), .dir), .parent = namespace_panes }, + }) |case| { + const result = look_up(p, case.node, ".."); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expectEqual(case.parent, result.reply.attr.node); + } +} + +test "errors and cons append to one +Errors buffer per directory" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + + const live = for (p.panes) |slot| { + if (slot) |q| if (q.file) |f| if (f.output) |o| if (std.meta.activeTag(o.from) == .errors) break q; + } else null; + try testing.expect(live == null); + + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .errors), "boom\n").reply.status); + _ = wr(p, @intFromEnum(SelfFile.cons), "again\n"); + + var found: usize = 0; + for (p.panes) |slot| { + const q = slot orelse continue; + const f = q.file orelse continue; + const o = f.output orelse continue; + if (std.meta.activeTag(o.from) != .errors) continue; + found += 1; + try testing.expectEqualStrings("boom\nagain\n", f.content); + try testing.expectEqualStrings("/+Errors", f.path); + } + try testing.expectEqual(@as(usize, 1), found); +} + +test "setattr truncation empties the body and answers fresh attributes" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + + const a = call(p, .{ .tag = 7, .op = .setattr, .node = Node.of(serial, .body), .truncate = true }); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(@as(u64, 0), a.reply.attr.size); + try testing.expectEqualStrings("", p.panes[0].?.file.?.content); + + _ = wr(p, Node.of(serial, .body), "new text\n"); + try testing.expectEqualStrings("new text\n", p.panes[0].?.file.?.content); + + const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); + try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); +} + +test "event records are acme's bytes, one per read, and .again when empty" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "sg "); + try testing.expect(p.fs.panes[0].events.empty()); + + const h = call(p, .{ .tag = 10, .op = .open, .node = event }); + try testing.expect(h.reply.handle != 0); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + p.fs.origin = 'M'; + _ = noteAction(p, 0, .body_exec, 1, 4, flag_builtin, "ell"); + _ = noteAction(p, 0, .body_delete, 0, 3, 0, ""); + try testing.expectEqualStrings("MX1 4 1 3 ell\n", rd(p, event, 0, 4096).bytes); + try testing.expectEqualStrings("MD0 3 0 0 \n", rd(p, event, 0, 4096).bytes); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); + + _ = noteAction(p, 0, .body_look, 0, 3, flag_filename, "one"); + try testing.expectEqual(E.INVAL, rd(p, event, 0, 4).errno()); + try testing.expectEqualStrings("ML0 3 4 3 one\n", rd(p, event, 0, 4096).bytes); + + const big = "z" ** max_record_text; + _ = noteAction(p, 0, .body_exec, 0, max_record_text, 0, big); + try testing.expectEqualStrings("MX0 256 0 0 \n", rd(p, event, 0, 4096).bytes); + + _ = call(p, .{ .tag = 11, .op = .release, .node = event, .handle = h.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +fn drainEvents(p: *Pardes, node: u64, store: []u8, out: [][]const u8) [][]const u8 { + var used: usize = 0; + var n: usize = 0; + while (n < out.len) { + const a = rd(p, node, 0, 4096); + if (a.reply.status != .ok) break; + @memcpy(store[used..][0..a.bytes.len], a.bytes); + out[n] = store[used..][0..a.bytes.len]; + used += a.bytes.len; + n += 1; + } + return out[0..n]; +} + +test "a write through the filesystem is reported once, attributed to the file it came through" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = call(p, .{ .tag = 40, .op = .open, .node = event }); + var store: [4096]u8 = undefined; + var slots: [16][]const u8 = undefined; + _ = drainEvents(p, event, &store, &slots); + + _ = wr(p, Node.of(serial, .body), "three\n"); + const body_recs = drainEvents(p, event, &store, &slots); + try testing.expect(body_recs.len >= 1); + try testing.expectEqualStrings("EI8 14 0 6 three\n\n", body_recs[0]); + for (body_recs[1..]) |r| { + try testing.expectEqual(@as(u8, 'E'), r[0]); + try testing.expect(Action.fromChar(r[1]).?.onTag()); + } + + _ = wr(p, Node.of(serial, .addr), "1"); + _ = wr(p, Node.of(serial, .data), "ONE\n"); + const data_recs = drainEvents(p, event, &store, &slots); + try testing.expectEqual(@as(usize, 2), data_recs.len); + try testing.expectEqualStrings("FD0 3 0 0 \n", data_recs[0]); + try testing.expectEqualStrings("FI0 3 0 3 ONE\n", data_recs[1]); + try testing.expectEqual(Status.again, rd(p, event, 0, 4096).reply.status); +} + +test "two event readers each count once, and the second closing leaves the first" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const event = Node.of(serialOf(p), .event); + + _ = call(p, .{ .tag = 12, .op = .open, .node = event }); + _ = call(p, .{ .tag = 13, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.panes[0].readers); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = call(p, .{ .tag = 14, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].readers); + try testing.expect(p.fs.scripted(0)); + + _ = call(p, .{ .tag = 15, .op = .release, .node = Node.of(serialOf(p), .body) }); + try testing.expectEqual(@as(u16, 1), p.fs.listeners); + + _ = call(p, .{ .tag = 16, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + _ = call(p, .{ .tag = 17, .op = .release, .node = event }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); +} + +test "a pane deleted while its event file is open leaves no suppression behind" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + + const a = call(p, .{ .tag = 18, .op = .open, .node = event }); + const b = call(p, .{ .tag = 19, .op = .open, .node = event }); + try testing.expectEqual(@as(u16, 2), p.fs.listeners); + + _ = wr(p, Node.of(serial, .ctl), "delete\n"); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); + _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + + try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); +} + +test "writing an event record back performs the action it names" { + const gpa = testing.allocator; + const p = try withFile(gpa, "Msg fs-ran\n"); + defer p.deinit(); + const serial = serialOf(p); + const event = Node.of(serial, .event); + const pane = p.panes[0].?; + + const w = wr(p, event, "FX0 10\n"); + try testing.expectEqual(Status.ok, w.reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + try testing.expectEqual(Status.ok, wr(p, event, "FX0 10\nFX0 10\n").reply.status); + try testing.expectEqualStrings("fs-ran", pane.msg[0..pane.msg_len]); + + pane.msg_len = 0; + for ([_][]const u8{ + "FX0 10\nFQ0 1\n", // unknown type character + "FX0 999\n", // out of range + "FX0 10", // no newline + "FX5 1\n", // q0 > q1 + "FD0 3\n", // a report, not a request + "F\n", + }) |bad| { + try testing.expectEqual(E.INVAL, wr(p, event, bad).errno()); + try testing.expectEqual(@as(usize, 0), pane.msg_len); + } + + _ = call(p, .{ .tag = 23, .op = .open, .node = event }); + p.fs.origin = 'K'; + _ = wr(p, event, "KX0 10\n"); + try testing.expectEqual(@as(u8, 'F'), p.fs.origin); +} + +test "a pane that is not a terminal has no pty/ at all" { + const gpa = testing.allocator; + const p = try withFile(gpa, "hello\n"); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty").errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 1, + .op = .getattr, + .node = Node.of(serial, .pty), + }).errno()); + try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .pty_status), 0, 256).errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_ctl), "winsize 80 24\n").errno()); + try testing.expectEqual(E.NOENT, rdir(p, Node.of(serial, .pty), 0).errno()); + try testing.expectEqual(E.NOENT, call(p, .{ + .tag = 2, + .op = .open, + .node = Node.of(serial, .pty_data), + }).errno()); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + + var buf: [32]Dirent = undefined; + const files = dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 10), files.len); + try testing.expect(nameAt(files, "pty") == null); + + try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); + try testing.expectEqual(E.NOENT, look_up(p, dir, "status").errno()); + + const before = p.next_serial; + try testing.expectEqual(E.NOENT, look_up(p, @intFromEnum(SelfFile.new), "pty").errno()); + try testing.expectEqual(before, p.next_serial); +} + +test "a terminal pane's pty/ holds exactly ctl, status and data" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const dir = Node.of(serial, .dir); + + const pty = look_up(p, dir, "pty"); + try testing.expectEqual(Node.of(serial, .pty), pty.reply.attr.node); + try testing.expect(pty.reply.attr.dir); + try testing.expectEqual(@as(u16, 0o500), pty.reply.attr.mode); + + var buf: [32]Dirent = undefined; + const files = dirents(rdir(p, dir, 0).bytes, &buf); + try testing.expectEqual(@as(usize, 11), files.len); + try testing.expect(nameAt(files, "pty").?.dir); + + const inside = dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); + try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqualStrings("ctl", inside[0].name); + try testing.expectEqualStrings("status", inside[1].name); + try testing.expectEqualStrings("data", inside[2].name); + for (inside) |d| try testing.expect(!d.dir); + try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); + + const ctl = look_up(p, Node.of(serial, .pty), "ctl"); + try testing.expectEqual(Node.of(serial, .pty_ctl), ctl.reply.attr.node); + try testing.expectEqual(@as(u16, 0o200), ctl.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o400), look_up(p, Node.of(serial, .pty), "status").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "body").errno()); + try testing.expectEqual(E.NOENT, look_up(p, Node.of(serial, .pty), "pty").errno()); + + try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .pty_ctl), "x").errno()); + try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .pty_ctl), 0).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty), 0, 16).errno()); + try testing.expectEqual(E.PERM, rd(p, Node.of(serial, .pty_ctl), 0, 16).errno()); + try testing.expectEqual(E.PERM, wr(p, Node.of(serial, .pty_status), "x").errno()); +} + +test "every pty/ctl verb, and every refusal" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const ctl = Node.of(serialOf(p), .pty_ctl); + + const pane = p.panes[0].?; + const cols = pane.cols; + const rows = pane.rows; + const ws = wr(p, ctl, "winsize 132 44\n"); + try testing.expectEqual(@as(u32, "winsize 132 44\n".len), ws.reply.written); + try testing.expectEqual(@as(u16, 132), ws.winsize.?.cols); + try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); + try testing.expectEqual(cols, pane.cols); + try testing.expectEqual(rows, pane.rows); + + for ([_]struct { line: []const u8, want: pardes.PtySignal }{ + .{ .line = "sig INT", .want = .int }, + .{ .line = "sig TERM", .want = .term }, + .{ .line = "sig HUP", .want = .hup }, + .{ .line = "sig QUIT", .want = .quit }, + .{ .line = "sig KILL", .want = .kill }, + }) |c| { + const a = wr(p, ctl, c.line); + try testing.expectEqual(Status.ok, a.reply.status); + try testing.expectEqual(c.want, a.signal.?); + } + + const ex = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, ex.reply.status); + try testing.expect(ex.spawned); + + const both = wr(p, ctl, "winsize 100 30\nsig TERM"); + try testing.expectEqual(@as(u16, 100), both.winsize.?.cols); + try testing.expectEqual(pardes.PtySignal.term, both.signal.?); + + for ([_][]const u8{ + "winsize", // no arguments + "winsize 80", // one argument + "winsize 80 24 extra", // three + "winsize 0 24", // zero is "unknown", never a width + "winsize 80 0", + "winsize -1 24", // not a decimal + "winsize 999999 24", // wider than a u16 + "sig", // no name + "sig INT TERM", // two + "sig SIGINT", + "sig int", // lower case + "sig 9", // a number is one platform's number + "sig USR1", // a real signal, deliberately not offered + "exec /bin/sh", // the effect carries no argv; refused, never ignored + "raw", + "cooked", + "winsize 80 24\nbogus", // a good verb beside a bad one + "bogus\nwinsize 80 24", + "name x", // a `ctl` verb; the two files share no vocabulary + "del", + }) |bad| { + const a = wr(p, ctl, bad); + try testing.expectEqual(E.INVAL, a.errno()); + try testing.expect(a.winsize == null); + try testing.expect(a.signal == null); + try testing.expect(!a.spawned); + } + + const spaced = wr(p, ctl, "\n winsize 90 20 \n\n"); + try testing.expectEqual(Status.ok, spaced.reply.status); + try testing.expectEqual(@as(u16, 90), spaced.winsize.?.cols); + try testing.expectEqual(Status.ok, wr(p, ctl, "").reply.status); +} + +const FakeTty = struct { + taken: bool, + + const vtable: pardes.Host.VTable = .{ .tty_taken = answer }; + + fn answer(ctx: ?*anyopaque, pane: u8) bool { + _ = pane; + const f: *FakeTty = @ptrCast(@alignCast(ctx.?)); + return f.taken; + } +}; + +test "owned cwd pty exec rejects long paths before applying its batch" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + const pane = p.panes[0].?; + const ctl = Node.of(pane.serial, .pty_ctl); + var path: [1025]u8 = @splat('d'); + path[0] = '/'; + p.setCwd(0, &path); + for ([_][]const u8{ "exec\n", "winsize 100 30\nexec\n", "sig TERM\nexec\n" }) |command| { + const result = wr(p, ctl, command); + try testing.expectEqual(E.INVAL, result.errno()); + try testing.expect(!result.spawned); + try testing.expect(result.signal == null and result.winsize == null); + } + try testing.expectEqualStrings(&path, pane.cwdSlice()); + p.setCwd(0, path[0..pardes.effect_path_cap]); + const accepted = wr(p, ctl, "exec\n"); + try testing.expectEqual(Status.ok, accepted.reply.status); + try testing.expect(accepted.spawned); +} + +test "owned cwd name promotion releases the former directory" { + const p = try withTerm(testing.allocator); + defer p.deinit(); + p.newScratchBelow(0); + const id = p.active; + const pane = p.panes[id].?; + try pane.setOwnedCwd("/old/directory"); + const result = wr(p, Node.of(pane.serial, .ctl), "name saved.txt\n"); + try testing.expectEqual(Status.ok, result.reply.status); + try testing.expect(pane.cwd == .none); + try testing.expect(pane.file.?.output == null); + try testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); + try testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); +} + +test "pty/status reports the grid and who holds the tty" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const pane = p.panes[0].?; + const status = Node.of(pane.serial, .pty_status); + + const a = rd(p, status, 0, 256); + try testing.expectEqual(Status.ok, a.reply.status); + var want: [64]u8 = undefined; + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + try testing.expectEqualStrings(whole, a.bytes); + try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); + try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); + + var probe: FakeTty = .{ .taken = true }; + p.host = .{ .ctx = &probe, .vtable = &FakeTty.vtable }; + const held = rd(p, status, 0, 256); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + held.bytes, + ); +} + +test "pty/data writes at the shell and reads the raw stream" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const data = Node.of(serial, .pty_data); + + const w = call(p, .{ .tag = 2, .op = .write, .node = data, .off = 999, .data = "ls -l\r" }); + try testing.expectEqual(@as(u32, 6), w.reply.written); + try testing.expectEqualStrings("ls -l\r", w.pty()); + try testing.expectEqual(@as(u32, 1), wr(p, data, "a\xC3").reply.written); + try testing.expectEqual(@as(u32, 0), wr(p, data, "").reply.written); + + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "unwatched" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.items.len); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(u16, 0), p.fs.listeners); + try testing.expect(!p.fs.scripted(0)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "hello" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("hello", rd(p, data, 0, 64).bytes); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdef" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("ab", rd(p, data, 0, 2).bytes); + try testing.expectEqualStrings("cd", rd(p, data, 0, 2).bytes); + p.update(.{ .output = .{ .pane = 0, .bytes = "ghi" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("efghi", rd(p, data, 0, 64).bytes); + + p.update(.{ .output = .{ .pane = 0, .bytes = "orphan" } }); + while (p.nextEffect()) |_| {} + _ = call(p, .{ .tag = 6, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 0), p.fs.panes[0].pty_readers); + try testing.expectEqual(@as(usize, 0), p.fs.panes[0].pty_out.buf.capacity); + try testing.expectEqual(Status.again, rd(p, data, 0, 64).reply.status); + + _ = call(p, .{ .tag = 7, .op = .open, .node = data }); + _ = call(p, .{ .tag = 8, .op = .open, .node = data }); + _ = call(p, .{ .tag = 9, .op = .release, .node = data }); + try testing.expectEqual(@as(u16, 1), p.fs.panes[0].pty_readers); + p.update(.{ .output = .{ .pane = 0, .bytes = "still" } }); + while (p.nextEffect()) |_| {} + try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); +} + +test "the pty queue drops the oldest at its cap" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const data = Node.of(serialOf(p), .pty_data); + _ = call(p, .{ .tag = 5, .op = .open, .node = data }); + + const oldest: [4096]u8 = @splat('A'); + const rest: [4096]u8 = @splat('B'); + notePtyOutput(p, 0, &oldest); + for (0..queue_cap / rest.len + 4) |_| notePtyOutput(p, 0, &rest); + const q = &p.fs.panes[0].pty_out; + try testing.expect(q.buf.items.len - q.head <= queue_cap); + + var seen: usize = 0; + while (true) { + const a = rd(p, data, 0, 1 << 16); + if (a.reply.status == .again) break; + try testing.expect(std.mem.indexOfScalar(u8, a.bytes, 'A') == null); + if (a.bytes.len == 0) break; + seen += a.bytes.len; + } + try testing.expect(seen > 0 and seen <= queue_cap); +} diff --git a/src/fs9_client.zig b/src/fs9_client.zig deleted file mode 100644 index ad46dee4..00000000 --- a/src/fs9_client.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! `9p `: one pardes reading a file out of another pardes's tree. -//! -//! `src/fs9_service.zig`'s MIRROR, and the other half of `9P-2`: that file is a -//! listener with connections and hands each one a `ninep.Server`, this one -//! dials a single socket and drives a `ninep.Client` over it. They share the -//! socket NAMING and nothing else — `socketPath` is imported verbatim, so a -//! bare `9p work /1/body` resolves to exactly the path a `pardes --fs9 work` -//! bound, which is the whole point of having one spelling of it. -//! -//! WHAT IS HERE, and it is the same four things any non-blocking byte stream -//! needs: connect, read into `push`, `output` out through `send` and back -//! through `wrote`, and close. Everything above that is `src/9p.zig`, which is -//! freestanding and knows about neither sockets nor panes. -//! -//! IT BLOCKS, BRIEFLY AND BOUNDED, and that is a decision rather than an -//! oversight. `src/look.zig`'s `readFile` already blocks the frame on a disk -//! read — opening a file pane is a person waiting for a file — and a remote -//! read over a unix socket on the same machine is the same wait with a context -//! switch in it. What makes it safe to say that is the BUDGET: `budget_ms` is -//! the deadline for the whole transaction, `poll` is what waits, and the -//! descriptor is non-blocking, so a peer that stops answering costs one -//! `budget_ms` pause and a message on the message row rather than a wedged -//! editor. The alternative — a request queued into the frame loop, a state -//! machine per outstanding fetch, a pane that fills in later — is an async -//! runtime, and `docs/9p.typ` §12.5 is explicit that this design does not get -//! one. -//! -//! WHY THE HOST AND NOT THE CORE. A socket is `std.c`, and `src/9p.zig` must -//! keep compiling for `wasm32-freestanding` and the board's -//! `riscv32-freestanding`; the same `ninep.Client` runs over -//! `src/esp32p4/uart.zig` with no line of this file involved. So the split is -//! the one the server half already made: protocol in the freestanding file, -//! descriptor here. -//! -//! Linux and darwin, like every other unix socket in the tree. Anywhere else -//! `supported` is false and the word is not registered at all. -const std = @import("std"); -const libc = std.c; -const nested = @import("nested.zig"); -const ninep = @import("9p.zig"); -const fs9_service = @import("fs9_service.zig"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const output_pane = @import("output_pane.zig"); - -/// Unix sockets, which is all this needs — `fs9_service`'s own predicate, so a -/// build that can serve 9P can dial it and one that cannot has neither. -pub const supported = fs9_service.supported; - -/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. -const sun_path_len = nested.sun_path_len; - -/// The deadline for the WHOLE transaction: connect, handshake, attach, walk, -/// open, every read, clunk. -/// -/// TWO SECONDS, and the number is about the human rather than about the wire. -/// On a local socket the whole exchange is six round trips and some memcpys — -/// microseconds — so any wait long enough to notice means the far end is not -/// answering, and the useful thing to do about that is say so. Two seconds is -/// long enough that a busy editor on the other side finishing its frame is -/// never mistaken for a dead one, and short enough that a mistyped socket name -/// on a path that happens to exist does not feel like a hang. -pub const budget_ms: i64 = 2000; - -/// The most bytes one `9p` will carry into a pane. -/// -/// A MEGABYTE, which is a quarter of `look.zig`'s cap for a virtual file -/// (`read_stream_max_bytes`) and for a sharper reason: what is on the other end -/// is a synthetic tree of live editor state, where the biggest file is one -/// pane's `body`. A megabyte of it is a large source file; ten megabytes is -/// somebody pointing this word at a `/dev/zero` equivalent, and a read loop -/// with no cap would spend the whole budget filling the heap. -pub const max_bytes: u64 = 1 << 20; - -/// The deepest path this word will walk. -/// -/// `MAXWELEM` is sixteen elements per `Twalk` and a deeper path is legal — the -/// client splits it into chunks and `transact` does — so this is not a protocol -/// bound. It is a bound on the ARGUMENT: acme's tree is two deep (`/1/body`), -/// two chunks is thirty-two, and a path with more elements than that is a typo -/// or a loop rather than a file. Refused rather than truncated, because a -/// truncated path names a different file. -pub const max_depth: usize = 2 * ninep.max_welem; - -/// What the far end reports in `Rstat`'s three name fields (`uid`, `gid`, -/// `muid`) for everything we touch, because `ninep.Server` records the -/// attach's `uname` and quotes it back. -/// -/// A CONSTANT AND NOT `$USER`: the socket's permissions are the identity here -/// (0600, in a 0700 per-user directory — docs/9p.typ §10), so this string is -/// not a credential and cannot become one. What it is for is the operator -/// reading `ls -l` on the far side, and "pardes" tells them which program -/// walked their tree, which a login name they already share with it does not. -const uname = "pardes"; - -/// Everything this word can refuse, and each one is a different thing to do -/// about it. -pub const Error = error{ - MissingDial, - MissingPath, - /// More than `max_depth` elements. - PathTooDeep, - /// The dial names no address we can form: an empty name, a name with a - /// separator or a NUL in it, a path past `sun_path`, or no runtime - /// directory to resolve a bare name against. - BadDial, - /// `socket(2)` or `connect(2)` said no: nothing is listening on that - /// socket, or its permissions are not ours. The overwhelmingly common - /// case, and it means "that pardes is not running with `--fs9`". - Dial, - /// The peer closed mid-transaction. - Hangup, - /// `budget_ms` elapsed. See there. - Timeout, - /// The stream stopped being 9P: `ninep.Client` went dead, or a reply - /// arrived whose shape does not answer the request it was tagged for. - /// Nothing can be resynchronised from here. - Botch, - /// The far end answered `Rerror`. Its own string goes on the message row — - /// see `fetch` — so this value only says "reported already". - Remote, - /// The path names a directory. A directory READ is a run of `stat` - /// records rather than text, so opening it in a pane would show a person - /// the wire format; `9p` names files. - IsDirectory, - /// The walk stopped short: some element of the path is not there. Distinct - /// from `Remote` because a partial walk is a SUCCESSFUL `Rwalk` with fewer - /// qids and carries no message to report. - NotFound, - /// Past `max_bytes`. - FileTooLarge, - /// The pane that asked went away while this was in flight. - MissingPane, -}; - -/// The far end's own words, copied out of the client's input buffer before the -/// connection is torn down and the buffer with it. `ninep.errmax` is the buffer -/// a Plan 9 client has for an error string, so it is the right size for one. -const RemoteError = struct { - buf: [ninep.errmax]u8 = undefined, - len: usize = 0, - - /// Returns the error so that every call site is `return remote.set(e)`. - fn set(r: *RemoteError, msg: []const u8) error{Remote} { - r.len = @min(msg.len, r.buf.len); - @memcpy(r.buf[0..r.len], msg[0..r.len]); - return error.Remote; - } - - fn text(r: *const RemoteError) []const u8 { - return r.buf[0..r.len]; - } -}; - -/// `9p ` — walk to a remote file, read it, and open the bytes in a -/// pane. -/// -/// The pane is an ORDINARY OUTPUT BUFFER, which is what `src/board_memory.zig` -/// puts a hexdump in and what `Grep` puts its rows in: a file pane with an -/// `output` origin, so every motion, chord, search and Look works on it for -/// free. Deliberately NOT a real file pane, even though the bytes came from -/// `look.readFile`'s own kind of read: `file_pane.open` arms a file WATCH on -/// the path it was given, and there is no local path here for `inotify` to -/// watch — the bytes live in another process's memory. An output buffer is the -/// existing answer to "text with no file behind it". -/// -/// Identified by the WHOLE argument, so `9p work /1/body` and `9p work /2/body` -/// are two panes and running either again refills its own. -pub fn fetch(p: *Pardes, id: usize, argument: []const u8) !void { - const a = std.mem.trim(u8, argument, " \t\r\n"); - const args = try parse(a); - var names: [max_depth][]const u8 = undefined; - const n = try elements(args.path, &names); - - var sock_buf: [sun_path_len]u8 = undefined; - const sock = resolve(&sock_buf, args.dial) orelse return Error.BadDial; - - var remote: RemoteError = .{}; - const content = fetchBytes(p.gpa, sock, names[0..n], &remote) catch |err| { - if (err != Error.Remote) return err; - // The far end's own wording, which is the whole error ABI in base - // 9P2000 (docs/registry.typ `9P-4`) — reported verbatim rather than - // mapped to one of ours, because it is the only thing that says which - // of the eight operations the other side objected to and why. - var buf: [ninep.errmax + 8]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&buf, "9p: {s}", .{remote.text()}) catch "9p: refused"); - return; - }; - const pane = p.panes[id] orelse { - p.gpa.free(content); - return Error.MissingPane; - }; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - try output_pane.fillResults(p, id, dir, .{ .cmd = .@"9p" }, a, content, null); -} - -const Args = struct { dial: []const u8, path: []const u8 }; - -/// ` `, split at the FIRST run of whitespace and not tokenized. -/// -/// The path keeps its spaces, because a pane's name in acme's tree can have -/// them and a path is the last argument: `9p work /1/tag` and -/// `9p work /a name/body` both have exactly one reading. The dial cannot have -/// them, and does not need to — it is a socket name or a socket path. -fn parse(a: []const u8) Error!Args { - if (a.len == 0) return Error.MissingDial; - const cut = std.mem.indexOfAny(u8, a, " \t") orelse return Error.MissingPath; - const path = std.mem.trim(u8, a[cut..], " \t\r\n"); - if (path.len == 0) return Error.MissingPath; - return .{ .dial = a[0..cut], .path = path }; -} - -/// A path into `Twalk` elements. Separators are collapsed and a trailing one is -/// dropped, so `/1/body`, `1/body` and `//1/body/` are one file — the -/// normalisation every shell already does, done here because 9P has no -/// pathnames at all and a client that forwarded an empty element would be -/// asking for a file called "". -/// -/// ZERO ELEMENTS is the root, which is legal and is a directory; `transact` -/// refuses it there, where every other directory is refused too. -fn elements(path: []const u8, out: *[max_depth][]const u8) Error!usize { - var n: usize = 0; - var it = std.mem.tokenizeScalar(u8, path, '/'); - while (it.next()) |name| { - if (n == out.len) return Error.PathTooDeep; - out[n] = name; - n += 1; - } - return n; -} - -/// The dial, as an address. -/// -/// TWO SPELLINGS, told apart by a separator, and the distinction is the one a -/// person already makes: a NAME is what `pardes --fs9 work` was started with, -/// and it resolves through `fs9_service.socketPath` — the same function that -/// bound it, so the two can never drift. A PATH is taken as given, which is -/// what you need for a socket somewhere else entirely: a bind-mounted -/// container, a different user's runtime directory, an `ssh -L` forward. -fn resolve(buf: *[sun_path_len]u8, dial: []const u8) ?[:0]const u8 { - if (dial.len == 0) return null; - if (std.mem.indexOfScalar(u8, dial, '/') != null) { - if (std.mem.indexOfScalar(u8, dial, 0) != null) return null; - return std.fmt.bufPrintSentinel(buf, "{s}", .{dial}, 0) catch null; - } - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse return null; - return fs9_service.socketPath(buf, dir, dial); -} - -/// One dialled connection: the descriptor, the deadline, the client and its -/// three buffers. -/// -/// HEAP-ALLOCATED by `fetchBytes`, for `fs9_service.Listener`'s reason and one -/// more: `cl.in` and `cl.out` are slices INTO this struct, so it must never be -/// moved once `cl` is initialised, and at three msizes it is 24 KiB, which does -/// not belong on the frame's stack. -/// -/// The msize is `fs9_service.msize`, the one number the serving side is already -/// sized from. A client on the same machine reading the same tree has no reason -/// to pick a different one, and picking the same one means the handshake never -/// clamps. -const Session = struct { - fd: c_int, - /// `nowMs()` past which every wait gives up. - deadline: i64, - cl: ninep.Client = undefined, - in: [fs9_service.msize]u8 = undefined, - out: [fs9_service.msize]u8 = undefined, - /// A frame-local staging buffer rather than a read straight into the - /// client's tail: advancing `in_len` is `push`'s business, and reaching - /// past it to do it here would make this file a second author of - /// `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. Exactly - /// `fs9_service.fill`'s reasoning, from the other side. - stage: [fs9_service.msize]u8 = undefined, - - /// Wait for `events` on the descriptor, or give up. THE ONLY PLACE THIS - /// FILE BLOCKS, and the only place the budget is spent. - fn wait(s: *Session, events: i16) Error!void { - while (true) { - const left = s.deadline - nowMs(); - if (left <= 0) return Error.Timeout; - var fds = [1]libc.pollfd{.{ .fd = s.fd, .events = events, .revents = 0 }}; - const ready = libc.poll(&fds, 1, @intCast(@min(left, budget_ms))); - if (ready < 0) { - if (libc.errno(ready) == .INTR) continue; - return Error.Hangup; - } - if (ready == 0) return Error.Timeout; - // What we asked for wins over HUP: a peer that wrote a reply and - // then closed reports both at once, and those bytes are ours. - if (fds[0].revents & events != 0) return; - return Error.Hangup; - } - } - - /// Push everything the client owes the wire, and nothing else. Split out of - /// `settle` so that `dropNoWait` can send a message it will never collect a - /// reply for. Bounded by the same deadline `wait` enforces. - fn flush(s: *Session) Error!void { - while (s.cl.output().len != 0) { - try s.wait(poll_out); - const bytes = s.cl.output(); - const sent = libc.send(s.fd, bytes.ptr, bytes.len, nosignal); - if (sent < 0) switch (libc.errno(sent)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - // No progress and no error: looping on it is a spin, and a - // spin in here is the editor at 100% of a core. - if (sent == 0) return Error.Hangup; - s.cl.wrote(@intCast(sent)); - } - } - - /// Drive the client until the one outstanding request answers: flush what - /// we owe, collect if a reply is already buffered, otherwise wait and read. - /// - /// LOCK-STEP, deliberately, and it is worth saying why given that - /// `ninep.Client` allows sixteen requests in flight. A `9p` word is one - /// person waiting for one file, and its round trips are strictly ordered - /// anyway — you cannot read a fid you have not opened, or open one you have - /// not walked to. The one place pipelining would pay is the read loop, and - /// on a local socket at an 8 KiB msize a megabyte is 128 round trips of a - /// few microseconds each; buying that back would cost this file a request - /// window, an out-of-order reassembly buffer and a reason for both. The - /// CLIENT is where the sixteen tags live, so the board's runtime and any - /// future caller get them without this file having spent them. - fn settle(s: *Session) Error!ninep.Client.Done { - while (true) { - try s.flush(); - if (s.cl.take()) |done| return done; - if (s.cl.dead) return Error.Botch; - try s.wait(poll_in); - const room = s.cl.in.len - s.cl.in_len; - // Cannot happen: one reply is at most one msize and the buffer is - // exactly that, so a full buffer with nothing to take would mean - // the far end sent a frame it told us it would not. - if (room == 0) return Error.Botch; - const got = libc.read(s.fd, &s.stage, @min(room, s.stage.len)); - if (got == 0) return Error.Hangup; - if (got < 0) switch (libc.errno(got)) { - .INTR, .AGAIN => continue, - else => return Error.Hangup, - }; - const n = s.cl.push(s.stage[0..@intCast(got)]); - // The read was clamped to the room, so this cannot be short; it is - // asserted rather than ignored because silently dropping wire - // bytes desynchronises the stream, which is the one failure 9P - // cannot resynchronise from. - std.debug.assert(n == @as(usize, @intCast(got))); - } - } - - /// One request, one reply, and the two answers that are not the one asked - /// for folded into errors here so that `transact` reads as a script. - fn ask(s: *Session, req: ninep.Client.Request, remote: *RemoteError) Error!ninep.Client.Result { - _ = s.cl.submit(req) catch return Error.Botch; - const done = try s.settle(); - if (done.result == .fail) return remote.set(done.result.fail); - // The client already refuses a reply whose shape does not match the - // request's op (it kills the connection), so this can only be an - // `Rerror` we have just handled. Checked anyway: a `switch` here would - // be a second copy of that table. - if (std.mem.eql(u8, @tagName(done.result), @tagName(std.meta.activeTag(req)))) return done.result; - return Error.Botch; - } - - /// Clunk a fid and WAIT for the answer, because the caller is about to - /// reuse the number. `transact`'s walk alternates between fids 1 and 2, and - /// in-order processing is the only thing that makes that safe. - /// - /// Best effort otherwise: a refused clunk still frees the fid on both sides - /// (`clunk(5)`), so there is nothing here worth failing a fetch over. - fn drop(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - _ = s.settle() catch {}; - } - - /// Clunk a fid and do NOT wait. For the last one, where the descriptor is - /// closed on the next line and closing it frees every fid the connection - /// held — so the `Rclunk` is not merely unwanted, it is unobservable. - /// - /// Waiting for it cost the whole budget against a peer that answers - /// everything else and ignores clunks: measured at 2.005 s to deliver a - /// file that was already in hand, and 2.003 s to report an error decided - /// 1.4 ms in. The bytes still go out — a well-behaved peer gets its clunk - /// and frees the fid immediately rather than at hangup — but nothing here - /// reads the reply. - fn dropNoWait(s: *Session, fid: u32) void { - _ = s.cl.submit(.{ .clunk = .{ .fid = fid } }) catch return; - s.flush() catch {}; - } -}; - -/// The whole transaction, and the only function here that knows 9P's order of -/// operations: version, attach, walk, open, read to the end, clunk. -fn transact( - s: *Session, - names: []const []const u8, - out: *std.Io.Writer.Allocating, - remote: *RemoteError, -) !void { - // The handshake. A server that answers "unknown" has no dialect in common - // with us and leaves `msize` at zero, which is a connection nothing can be - // submitted on — reported as a botch, because there is no fallback ladder - // here to climb down. - _ = try s.ask(.{ .version = .{} }, remote); - if (s.cl.msize == 0) return Error.Botch; - - // Fid 0 is the root for the life of the connection; 1 and 2 alternate as - // the walk descends, so a chunked walk never needs a third. - const root: u32 = 0; - var here = (try s.ask(.{ .attach = .{ .fid = root, .uname = uname } }, remote)).attach; - var cur: u32 = root; - var next: u32 = 1; - - var i: usize = 0; - while (i < names.len) { - // `MAXWELEM` elements at a time, which is what makes a path deeper than - // sixteen work at all: every implementation refuses a seventeenth - // element, so a deep path is several walks with an intermediate fid. - const n = @min(ninep.max_welem, names.len - i); - const w = (try s.ask(.{ .walk = .{ - .fid = cur, - .newfid = next, - .names = names[i..][0..n], - } }, remote)).walk; - // A PARTIAL WALK IS A SUCCESS with fewer qids, and only a failure on - // the first element is an `Rerror`. So this comparison is the whole of - // "did the path exist", and skipping it is how a client ends up - // reading the wrong file. - if (w.nwqid != n) return Error.NotFound; - here = w.wqid[n - 1]; - if (cur != root) s.drop(cur); - cur = next; - next = if (next == 1) 2 else 1; - i += n; - } - defer s.dropNoWait(cur); - - // The qid the walk landed on already says what this is, so the refusal - // costs no round trip — and it catches the bare `/` too, which is zero - // elements and the root. - if (here.type & ninep.qtdir != 0) return Error.IsDirectory; - - _ = try s.ask(.{ .open = .{ .fid = cur, .mode = ninep.oread } }, remote); - - // Read to the end. 9P has no EOF flag: a reply SHORTER than the count is - // ordinary and means nothing, and a reply of ZERO bytes is the end of the - // file (`read(5)`). The offset advances by what came back and never by what - // was asked, which is the same rule a POSIX read loop follows. - var off: u64 = 0; - while (true) { - if (off >= max_bytes) return Error.FileTooLarge; - const want: u32 = @intCast(@min(@as(u64, s.cl.maxRead()), max_bytes - off)); - const data = (try s.ask(.{ .read = .{ .fid = cur, .offset = off, .count = want } }, remote)).read; - if (data.len == 0) return; - try out.writer.writeAll(data); - off += data.len; - } -} - -/// Dial, transact, and hand back the bytes — gpa-owned, the way -/// `look.readFile`'s are, so the pane adopts them with no second copy. -fn fetchBytes( - gpa: std.mem.Allocator, - sock: [:0]const u8, - names: []const []const u8, - remote: *RemoteError, -) ![]u8 { - if (comptime !supported) return Error.Dial; - // The deadline starts BEFORE the dial, because the dial is part of the - // transaction and used not to be bounded by anything at all. See `connect`. - const deadline = nowMs() +| budget_ms; - const fd = try connect(sock, deadline); - const s = gpa.create(Session) catch return error.OutOfMemory; - defer { - _ = libc.close(fd); - gpa.destroy(s); - } - s.* = .{ .fd = fd, .deadline = deadline }; - s.cl = .init(.{ .in = &s.in, .out = &s.out }); - - var out: std.Io.Writer.Allocating = .init(gpa); - errdefer out.deinit(); - try transact(s, names, &out, remote); - return out.toOwnedSlice(); -} - -/// Connect to a unix socket, non-blocking from the first moment there is -/// anything to wait for — which is the connect itself. -/// -/// This used to leave the connect BLOCKING, on the argument that a unix socket -/// either completes at once or refuses at once. That is true only while the -/// listener's accept queue has room. When it is full, Linux's -/// `unix_stream_connect` waits in `unix_wait_for_peer` for `sk_sndtimeo`, which -/// defaults to MAX_SCHEDULE_TIMEOUT — forever. The core is single-threaded, so -/// that is the whole editor: no frame, no keystroke, no filesystem request -/// served. Measured at 177 seconds against a peer that had called `listen` and -/// never `accept`, and it ended only because the peer was killed. Nothing in -/// pardes would have ended it, and the trigger needs no hostility — a peer that -/// is itself wedged does it, and a bare path names sockets pardes does not own. -/// -/// So the descriptor is non-blocking before the connect and the wait is spent -/// against the caller's deadline. Both refusals have to be handled and they are -/// different: on AF_UNIX a full backlog is EAGAIN, NOT the EINPROGRESS a TCP -/// connect would give, so EAGAIN retries until the deadline and EINPROGRESS -/// waits for POLLOUT and then asks SO_ERROR what actually happened. -fn connect(sock: [:0]const u8, deadline: i64) Error!c_int { - if (sock.len + 1 > sun_path_len) return Error.BadDial; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return Error.Dial; - nested.setCloexec(fd); - setNonblock(fd); - errdefer _ = libc.close(fd); - while (true) { - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) break; - switch (libc._errno().*) { - // The backlog is full. Nobody is obliged to drain it, so this is a - // poll on the clock rather than on the descriptor: there is no - // event to wait for, only room that may or may not appear. - @intFromEnum(libc.E.AGAIN), @intFromEnum(libc.E.INTR) => { - if (nowMs() >= deadline) return Error.Dial; - nap(2); - }, - // Someone is listening and the connect is under way. This one IS a - // descriptor event, so wait for it and then ask what it was. - @intFromEnum(libc.E.INPROGRESS), @intFromEnum(libc.E.ALREADY) => { - const left = deadline - nowMs(); - if (left <= 0) return Error.Dial; - var pfd: [1]libc.pollfd = .{.{ .fd = fd, .events = poll_out, .revents = 0 }}; - if (libc.poll(&pfd, 1, @intCast(@min(left, 1000))) <= 0) continue; - var err: c_int = 0; - var len: libc.socklen_t = @sizeOf(c_int); - if (libc.getsockopt(fd, libc.SOL.SOCKET, libc.SO.ERROR, @ptrCast(&err), &len) != 0) - return Error.Dial; - if (err == 0) break; - return Error.Dial; - }, - // Already connected by a previous round of this loop. - @intFromEnum(libc.E.ISCONN) => break, - else => return Error.Dial, - } - } - if (comptime nested.darwin) { - // linux says MSG_NOSIGNAL per write, darwin once per socket. A peer - // that dies mid-transaction must not take the editor down with it. - const on: c_int = 1; - _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); - } - return fd; -} - -/// The descriptor is non-blocking and `poll` does the waiting, because that is -/// the only shape in which the budget above is enforceable: a blocking `read` -/// has no deadline to give it. `fs9_service`'s own `setNonblock` is not reused -/// for its stated reason — importing a daemon into a path the tty and GUI -/// shells take would make a frontend transport a dependency of a builtin. -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// Milliseconds on the MONOTONIC clock, which is the only clock a deadline may -/// be measured against: the wall clock can be stepped, and an NTP correction -/// landing mid-fetch would turn a two-second budget into a hang or into an -/// instant timeout depending on which way it went. -/// -/// A clock that will not answer is reported as THE END OF TIME, so the budget -/// expires on the first wait rather than never — the saturating `+|` at the one -/// call site that adds to it is what makes that safe. -fn nowMs() i64 { - var ts: libc.timespec = undefined; - if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return std.math.maxInt(i64); - return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); -} - -const poll_in: i16 = @intCast(libc.POLL.IN); -const poll_out: i16 = @intCast(libc.POLL.OUT); - -/// Sleep a couple of milliseconds while a full accept backlog drains. There is -/// no descriptor to wait on for that — the room either appears or the deadline -/// arrives — so this is the one place here that waits on the clock. `poll` with -/// no descriptors is the portable spelling and needs no `nanosleep` import. -fn nap(ms: c_int) void { - _ = libc.poll(&[0]libc.pollfd{}, 0, ms); -} - -/// A dead peer must never kill the editor. linux says it per write, darwin once -/// per socket (see `connect`). -const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; - -const testing = std.testing; - -test "the argument is a dial and then the rest of the line" { - const a = try parse("work /1/body"); - try testing.expectEqualStrings("work", a.dial); - try testing.expectEqualStrings("/1/body", a.path); - - // A pane's name in acme's tree may contain spaces, and the path is the last - // argument, so it keeps them. - const spaced = try parse("work /a name/body"); - try testing.expectEqualStrings("work", spaced.dial); - try testing.expectEqualStrings("/a name/body", spaced.path); - - // A socket path as the dial, which is the other spelling. - const p = try parse("/run/user/1000/pardes-9p-work.sock /index"); - try testing.expectEqualStrings("/run/user/1000/pardes-9p-work.sock", p.dial); - try testing.expectEqualStrings("/index", p.path); - - try testing.expectError(Error.MissingDial, parse("")); - try testing.expectError(Error.MissingPath, parse("work")); - try testing.expectError(Error.MissingPath, parse("work ")); -} - -test "a path becomes walk elements, normalised the way a shell would" { - var out: [max_depth][]const u8 = undefined; - try testing.expectEqual(@as(usize, 2), try elements("/1/body", &out)); - try testing.expectEqualStrings("1", out[0]); - try testing.expectEqualStrings("body", out[1]); - - // Leading, trailing and doubled separators are one file, not four. - try testing.expectEqual(@as(usize, 2), try elements("1/body", &out)); - try testing.expectEqual(@as(usize, 2), try elements("//1//body//", &out)); - try testing.expectEqual(@as(usize, 1), try elements("/index", &out)); - - // Zero elements is the root, which is legal here and refused as a - // directory where every other directory is. - try testing.expectEqual(@as(usize, 0), try elements("/", &out)); - - // Deeper than two full walks is a typo, and truncating it would name a - // different file. - var deep: [8 * max_depth]u8 = @splat('/'); - for (0..max_depth + 1) |i| deep[i * 2 + 1] = 'a'; - try testing.expectError(Error.PathTooDeep, elements(deep[0 .. (max_depth + 1) * 2], &out)); -} - -test "a bare dial resolves to the socket --fs9 binds, and a path is taken as given" { - if (comptime !supported) return error.SkipZigTest; - var buf: [sun_path_len]u8 = undefined; - - // The one spelling both halves share: this must be the same name - // `fs9_service.socketPath` produces, or the friendly form dials nothing. - const named = resolve(&buf, "work").?; - try testing.expect(std.mem.endsWith(u8, named, "/pardes-9p-work.sock")); - var expect: [sun_path_len]u8 = undefined; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf).?; - try testing.expectEqualStrings(fs9_service.socketPath(&expect, dir, "work").?, named); - - // A separator makes it a path, verbatim. - const path = resolve(&buf, "/tmp/somewhere.sock").?; - try testing.expectEqualStrings("/tmp/somewhere.sock", path); - - // And the refusals: nothing to dial, and a NUL that would truncate the - // address into something else entirely. - try testing.expect(resolve(&buf, "") == null); - try testing.expect(resolve(&buf, "/tmp/a\x00b") == null); -} - -test "a dial with nothing listening is one error and not a wait" { - if (comptime !supported) return error.SkipZigTest; - // The overwhelmingly common failure — "that pardes is not running with - // --fs9" — and it must be immediate: `connect` on a unix socket with no - // listener is refused by the kernel with no timeout in it, which is why - // `budget_ms` is never spent here. - var names: [max_depth][]const u8 = undefined; - const n = try elements("/1/body", &names); - var remote: RemoteError = .{}; - const before = nowMs(); - try testing.expectError( - Error.Dial, - fetchBytes(testing.allocator, "/tmp/pardes-9p-no-such-socket.sock", names[0..n], &remote), - ); - try testing.expect(nowMs() - before < budget_ms); -} - -test "one fetch costs three msize buffers and nothing that grows" { - // The number this word adds to a session WHILE IT RUNS, and nothing after: - // the `Session` is freed before `fetch` returns and only the content - // survives, adopted by the pane. Heap rather than stack for the reason - // `Session` states. - try testing.expectEqual(@as(usize, fs9_service.msize), @as(usize, (Session{ .fd = -1, .deadline = 0 }).in.len)); - try testing.expect(@sizeOf(Session) <= 3 * fs9_service.msize + 256); - // The client's own state is a rounding error beside its buffers, which is - // the whole point of borrowing payloads out of `in` instead of copying - // them per tag. - try testing.expect(@sizeOf(ninep.Client) <= 256); -} diff --git a/src/fs9_service.zig b/src/fs9_service.zig deleted file mode 100644 index eebfe7bf..00000000 --- a/src/fs9_service.zig +++ /dev/null @@ -1,618 +0,0 @@ -//! `pardes --fs9`: what a native HOST has to decide to serve acme's control -//! filesystem over 9P2000 on a unix socket. -//! -//! `src/fs_service.zig`'s sibling, and deliberately a separate file: that one -//! is three decisions about a MOUNT (where to mount, when to drain, what a -//! pane shell is told), and this one is a listener with connections, buffers -//! and a socket path. They share the seam and nothing else — `Transport`, -//! `drain` and `Drained` all live there and are used verbatim here, which is -//! the whole point of `9P-2`: a second answer to the same three functions. -//! -//! WHAT IS HERE: a bound listening socket, a small fixed table of connections, -//! and the four things a non-blocking byte stream needs — accept, read into -//! `push`, `output` out through `write` and back through `wrote`, and hangup. -//! Everything above that is `src/9p.zig`, which is freestanding and knows -//! about neither sockets nor `acmefs.zig`; the instantiation -//! `ninep.Server(pardes.acmefs)` happens here and nowhere else. -//! -//! WHAT IS NOT HERE: the drain. One connection is one `Transport`, and the -//! host calls `fs_service.drain` per connection per frame at the same point in -//! the frame it drains the mount — see `drainAll`, and see -//! `detached/server.zig`'s `Source.ninep` for why a filesystem request must -//! not be served from inside a poll dispatch. -//! -//! Linux and darwin, like every other unix socket in the tree. On anything -//! else `open` returns null and the flag is quietly off. -const std = @import("std"); -const libc = std.c; -const nested = @import("nested.zig"); -const ninep = @import("9p.zig"); -const pardes = @import("pardes.zig"); -const fs_service = @import("fs_service.zig"); - -/// Diagnostics land where `fs_service`'s do and for its reason: stderr IS the -/// screen in the tty shell, so this is the `PARDES_LOG=1` copy. -const log = std.log.scoped(.fs9); - -/// Unix sockets, which is all this needs. `nested.supported` also demands a -/// way to name an arbitrary pid's executable, which no part of this asks. -pub const supported = builtin_unix; -const builtin_unix = @import("builtin").os.tag == .linux or nested.darwin; - -/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. -const sun_path_len = nested.sun_path_len; - -/// A THIRD prefix in the one per-user directory, beside nested.zig's -/// `pardes-.sock` and detached/server.zig's `pardes-detached-.sock`, -/// for the reason `nested.zig:39-44` gives: one directory vetted by different -/// predicates is exactly the divergence that naming prevents. That file's -/// sweeper unlinks any name whose digits name a dead pid, and this socket must -/// never look like one; the detached transport's `vetted` accepts only its own -/// prefix, so a 9P socket cannot be dialled by a frontend expecting `wire.zig` -/// either. -const prefix = "pardes-9p-"; - -/// The msize this host serves, and the ONE number both buffers are sized from. -/// -/// 8 KiB, which is `9P-17`'s clamp applied to the thing a client actually -/// reads: the largest single answer this tree produces is one pane's `body`, -/// and a client reading a megabyte of it does so in msize-sized `Tread`s -/// whatever this number is. So the only thing a larger msize buys is fewer -/// round trips on a LOCAL socket, and the only thing it costs is resident -/// memory in a daemon nobody is talking to. 8 KiB is two `Tread`s per screen -/// of text and comfortably above `ninep.min_msize` (4096), which is the floor -/// below which plan9port's `9p` and Linux's `v9fs` start refusing mounts with -/// `EINVAL` and no message. -pub const msize: u32 = 8192; - -/// Connections one session serves at once. -/// -/// FOUR, and it is not a guess about load: a 9P client here is a SCRIPT, and -/// the thing a script does is walk, read and clunk. What holds a connection -/// open for minutes is a blocked reader on `event` or `cons` — one per script -/// that is watching the editor — and beyond a handful of those the honest -/// answer is that somebody is using the wrong tool. The number is small on -/// purpose because a connection costs its buffers whether it is busy or idle: -/// MEASURED at 34,072 B each (a `Server` of 9,488 B plus `msize` in and twice -/// `msize` out) for 136,408 B of table, which is the whole of what `--fs9` -/// adds to a daemon's resident memory. A refused connect is also a diagnostic -/// a script author sees immediately, where a silently queued one is not. The -/// detached transport's `max_clients` is 32 because a frontend is a human's -/// window; this is not that. -pub const max_conns = 4; - -/// The 9P server, over the filesystem ABI `acmefs.zig` defines. This -/// instantiation is the only coupling between the freestanding protocol file -/// and the core, and it is a type parameter rather than an import for the -/// reason `9p.zig`'s `Server` doc comment gives. -const Srv = ninep.Server(pardes.acmefs); - -/// One connection: a socket, a server, and the server's two buffers. -/// -/// THE BUFFERS ARE FIELDS HERE, which is what `Srv`'s "no allocator" means -/// from the caller's side: `srv.in` and `srv.out` are slices INTO this struct, -/// so a `Conn` must never be moved or copied once `srv` is initialised. That -/// is why `Listener` is heap-allocated by `open` and held by pointer, and why -/// nothing below takes a `Conn` by value. -/// -/// `out` is twice `msize` because `Srv` requires it: one reply being written -/// out and one being built, which is what lets a reply be encoded the moment -/// the core answers with no "can I write yet" question anywhere in `9p.zig`. -const Conn = struct { - /// Non-negative exactly while the peer is connected. It goes to -1 the - /// moment the connection ends, which is BEFORE this slot is free: see - /// `draining`. - fd: c_int = -1, - /// The peer has gone and the server still owes the core `release` calls - /// for the fids it held. A dropped `event` fid without one leaves the - /// pane's reader count high forever (`acmefs.zig:1053-1061`), so the slot - /// stays occupied, with no descriptor, until `next()` runs dry. See - /// `Srv.hangup` and `drainAll`. - draining: bool = false, - /// When this peer connected, on the monotonic clock, or 0 when the clock - /// is unavailable. Read by `expire`: a connection that has not sent - /// `Tversion` within `greet_deadline_ms` is holding a slot by silence, - /// which with only four of them is a cheaper denial than the frontend - /// socket's thirty-two. `Server.msize == 0` is the "has not versioned yet" - /// flag, and version(5) requires `Tversion` before any other message, so - /// there is no legitimate client this can catch. - accepted_ms: i64 = 0, - /// Undefined until `accept` initialises it in place, which it may only do - /// through a pointer to this exact storage. - srv: Srv = undefined, - in: [msize]u8 = undefined, - out: [2 * msize]u8 = undefined, - - /// This connection's answer to `fs_service.Transport`. Thunked exactly - /// like `fuse.Fs.transport()`, and for its reason: a `*Conn` is not an - /// `*anyopaque` and a vtable cannot hold the typed function. - fn transport(c: *Conn) fs_service.Transport { - return .{ .ctx = c, .vtable = &transport_vtable }; - } - - const transport_vtable: fs_service.Transport.VTable = .{ - .retry = transportRetry, - .next = transportNext, - .reply = transportReply, - }; - - fn transportRetry(ctx: *anyopaque) ?pardes.acmefs.Req { - const c: *Conn = @ptrCast(@alignCast(ctx)); - return c.srv.retry(); - } - - fn transportNext(ctx: *anyopaque) ?pardes.acmefs.Req { - const c: *Conn = @ptrCast(@alignCast(ctx)); - return c.srv.next(); - } - - fn transportReply(ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void { - const c: *Conn = @ptrCast(@alignCast(ctx)); - c.srv.reply(r, bytes); - } -}; - -/// How long the 9P listener stays out of the poll set after an `accept` that -/// failed for a reason that persists — EMFILE and ENFILE above all. The same -/// number and the same argument as the frontend listener's own pause: the -/// connection is still in the backlog, `poll` is level triggered, and coming -/// straight back spins the core until some unrelated descriptor is freed. -const accept_pause_ms: i64 = 100; - -/// The listening socket and its connections. Heap-allocated because a `Conn` -/// holds slices into itself (see there) and because at three buffers per -/// connection this is ≈100 KiB, which does not belong in a host's struct. -pub const Listener = struct { - fd: c_int = -1, - /// Do not accept before this moment on the monotonic clock. Set when - /// `accept(2)` fails for a reason that leaves the connection in the backlog - /// — EMFILE and ENFILE — because a level-triggered poll then reports the - /// listener ready forever and coming straight back spins the core. Zero - /// means accepting normally. - paused_ms: i64 = 0, - /// The bound path, kept so teardown unlinks exactly what was created — - /// guarded on the fd, like nested.zig's and detached/server.zig's - /// `unlisten`. - path_buf: [sun_path_len]u8 = undefined, - path_len: usize = 0, - conns: [max_conns]Conn = @splat(.{}), - - /// The socket path, so a host can tell the operator where to dial. - pub fn path(l: *const Listener) []const u8 { - return l.path_buf[0..l.path_len]; - } - - /// Accept whatever is waiting, bounded. - /// - /// BOUNDED for detached/server.zig's `accept` reason: `poll` is level - /// triggered, so an unaccepted backlog reports ready forever and a peer - /// dialling in a loop would otherwise hold the core in here. And always - /// accepting, even with a full table, for the same reason — the surplus is - /// accepted and closed rather than left to spin the poll. - pub fn accept(l: *Listener) void { - if (comptime !supported) return; - for (0..max_conns + 1) |_| { - const fd = libc.accept(l.fd, null, null); - if (fd < 0) switch (libc.errno(fd)) { - // The ordinary exit: nothing more is queued. - .AGAIN => return, - // Retry: a signal, or a peer that gave up between the poll and - // the accept. Neither says anything about our capacity. - .INTR, .CONNABORTED => continue, - // Out of descriptors. The connection STAYS in the backlog, so a - // level-triggered poll reports the listener ready again at once - // and coming straight back spins the core until something - // unrelated frees an fd — measured at 99.8% of one, sustained. - // The frontend listener one file over solves it the same way. - else => { - l.paused_ms = nowMs() +| accept_pause_ms; - log.warn("--fs9: accept failed; pausing the listener for {d} ms", .{accept_pause_ms}); - return; - }, - }; - nested.setCloexec(fd); - setNonblock(fd); - if (comptime nested.darwin) { - // linux says MSG_NOSIGNAL per write, darwin once per socket. A - // script that dies mid-reply must not take the editor down. - const on: c_int = 1; - _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); - } - const c = for (&l.conns) |*cand| { - if (cand.fd < 0 and !cand.draining) break cand; - } else { - // No slot. Closing is the whole refusal: a 9P client that - // reads EOF instead of an `Rversion` reports a dial failure, - // which is the honest thing for it to say. - log.debug("--fs9: refusing a connection, all {d} slots busy", .{max_conns}); - _ = libc.close(fd); - continue; - }; - c.fd = fd; - c.draining = false; - c.accepted_ms = nowMs(); - c.srv = .init(.{ - .in = &c.in, - .out = &c.out, - .root = @intFromEnum(pardes.acmefs.TopFile.root), - }); - } - } - - /// How long a connection may hold a slot without saying `Tversion`. The - /// same five seconds and the same argument as the frontend socket's - /// `greet_deadline_ms` (`detached/server.zig`): a slot held by silence is - /// the same denial as a full queue, arrived at from the other end. Cheaper - /// here, because there are four slots rather than thirty-two and no - /// handshake to fake. - pub const greet_deadline_ms: i64 = 5000; - - /// Take back any slot whose peer connected and then said nothing. Called - /// once per frame beside the drain; the host folds `nextDue` into its poll - /// timeout so the deadline is kept on an otherwise idle session rather than - /// whenever some other descriptor happens to wake it. - pub fn expire(l: *Listener) void { - if (comptime !supported) return; - const now = nowMs(); - if (now == 0) return; // no clock; see `nowMs` - for (&l.conns, 0..) |*c, i| { - if (c.fd < 0 or c.srv.msize != 0) continue; - if (now - c.accepted_ms < greet_deadline_ms) continue; - log.debug("--fs9: slot {d} never sent Tversion; taking it back", .{i}); - l.drop(@intCast(i)); - } - } - - /// Is the listener worth polling this round? False while it is paused after - /// a persistent `accept` failure — leaving it in the set is exactly the - /// spin the pause exists to stop. - pub fn accepting(l: *const Listener) bool { - if (comptime !supported) return false; - if (l.fd < 0) return false; - if (l.paused_ms == 0) return true; - const now = nowMs(); - return now == 0 or now >= l.paused_ms; - } - - /// Milliseconds until the earliest greet deadline, or null when nothing is - /// waiting on the clock. Floored at zero so a deadline already past polls - /// once without blocking instead of blocking on a negative timeout. - pub fn nextDue(l: *const Listener) ?i32 { - if (comptime !supported) return null; - const now = nowMs(); - if (now == 0) return null; - var due: ?i64 = null; - // The pause is a clock deadline like the greet ones: without it here, - // an idle session would sleep through the moment the listener is - // allowed back and only notice on the next unrelated wake. - if (l.paused_ms > now) due = l.paused_ms; - for (&l.conns) |*c| { - if (c.fd < 0 or c.srv.msize != 0) continue; - const at = c.accepted_ms + greet_deadline_ms; - due = if (due) |d| @min(d, at) else at; - } - const at = due orelse return null; - return @intCast(@max(0, at - now)); - } - - /// The monotonic clock in milliseconds, or 0 when there is none — which - /// every caller reads as "no deadlines this round" rather than as a time. - fn nowMs() i64 { - var ts: libc.timespec = undefined; - if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; - return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); - } - - /// Read one chunk off connection `i` and hand it to the server. - /// - /// ONE read per connection per round, which is detached/server.zig's - /// `receive` rule: a script in a `while true` loop gets one turn and then - /// the loop moves on to the other connections and to the frame. - /// - /// Sized to what the server can TAKE rather than to the socket, because - /// `push` returns short on back-pressure and bytes read past that point - /// would have nowhere to go. Zero room is not an error and not a hangup: - /// the buffer holds a message the core has not finished with, and the next - /// drain frees it. - pub fn fill(l: *Listener, i: u8) void { - if (comptime !supported) return; - const c = &l.conns[i]; - // FIRST, and before the room guard below, which is the trap: once - // `startFrame` gives up on the framing, `in_len` is stuck at `in.len` - // for good, so `room == 0` returns without reading, `poll` is level - // triggered, the descriptor reports ready again immediately, and the - // loop never sleeps. Measured at 99.7% of a core, sustained, reachable - // by any process with the uid in one `write(2)`. - if (c.srv.dead) return l.drop(i); - const room = c.srv.in.len - c.srv.in_len; - if (room == 0) return; - // A frame-local staging buffer rather than a read straight into the - // server's tail: advancing `in_len` is `push`'s business, and reaching - // past it to do it here would make this file a second author of - // `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. - var buf: [msize]u8 = undefined; - const got = libc.read(c.fd, &buf, @min(room, buf.len)); - if (got == 0) return l.drop(i); // clean EOF: the script left - if (got < 0) return switch (libc.errno(got)) { - .INTR, .AGAIN => {}, - else => l.drop(i), - }; - const n = c.srv.push(buf[0..@intCast(got)]); - // The stream stopped being 9P. `Server.startFrame` sets `dead` when the - // framing is unrecoverable — a `size[4]` of zero, or one larger than the - // input buffer — and `push` then takes NOTHING, for good, because there - // is nowhere to resynchronise to in a protocol whose only frame marker - // is the length you were just lied to about. - // - // This has to be checked before the assert below, and the assert is why: - // it used to fire, and firing meant `unreachable` on the daemon's own - // thread — every pane, every attached frontend and the FUSE mount gone, - // reached by any client that sends one bad length and then one more - // byte. The socket is 0600 in a 0700 directory, but the whole point of - // `--fs9` is that other programs dial it, so a buggy one is enough. - if (c.srv.dead) return l.drop(i); - // NOW it cannot happen: the read was clamped to the room and the only - // other refusal is the one handled above. Asserted rather than ignored - // because silently dropping wire bytes desynchronises the stream, which - // is the one failure 9P cannot resynchronise from. - std.debug.assert(n == @as(usize, @intCast(got))); - } - - /// Push what the kernel will take of what this connection owes, and leave - /// the rest for a POLLOUT. detached/server.zig's `flush` on a 9P byte - /// FIFO instead of an `ArrayList`, and the rule it exists for is the same: - /// a peer that will not read must never block the editor. - pub fn flush(l: *Listener, i: u8) void { - if (comptime !supported) return; - const c = &l.conns[i]; - if (c.fd < 0) return; - while (true) { - const bytes = c.srv.output(); - if (bytes.len == 0) return; - const n = libc.send(c.fd, bytes.ptr, bytes.len, nosignal); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - .AGAIN => return, - else => return l.drop(i), - }; - // No progress and no error. Looping on it is a spin, and a spin in - // here is the whole session at 100% of a core with no syscall for - // a signal to interrupt — host_io.zig's `writeFd` rule. - if (n == 0) return; - c.srv.wrote(@intCast(n)); - } - } - - /// Whether this connection wants POLLOUT: only while it owes bytes, which - /// is the same rule and the same reason as a client's and a pty's — asking - /// for it unconditionally makes every idle socket a ready descriptor and - /// turns the poll into a spin. - pub fn owes(l: *const Listener, i: u8) bool { - return l.conns[i].srv.output().len != 0; - } - - /// True when this slot has a live descriptor to poll. - pub fn live(l: *const Listener, i: u8) bool { - return l.conns[i].fd >= 0; - } - - /// This connection is over: out of the poll set, out of the process — but - /// NOT out of the table, because the server still owes the core a - /// `release` per open fid. See `Conn.draining`. - pub fn drop(l: *Listener, i: u8) void { - const c = &l.conns[i]; - if (c.fd >= 0) { - _ = libc.close(c.fd); - c.fd = -1; - } - if (c.draining) return; - c.srv.hangup(); - c.draining = true; - } - - /// This connection's `Transport`, or null when the slot has no work: the - /// peer never arrived, or it left and its fids are already released. - /// - /// THE HOST DRAINS, not this file, and that is not a style choice. A reply - /// reaches a transport through the host's `push_fs_reply`, so the host has - /// to know WHICH transport the request being served came from — the - /// "routing origin" docs/9p.typ's layering table (`O2 two listeners`) - /// names as the thing a second listener costs. Handing the transport out - /// here and taking the `Drained` back in `settle` is that origin made - /// explicit: the host sets it, calls `fs_service.drain`, clears it. A - /// `drainAll` that hid the loop in this file could not, and every 9P reply - /// went to the FUSE mount instead — measured, as a `Tattach` that never - /// came back. - pub fn transport(l: *Listener, i: u8) ?fs_service.Transport { - if (comptime !supported) return null; - const c = &l.conns[i]; - if (c.fd < 0 and !c.draining) return null; - return c.transport(); - } - - /// What one connection's drain came to: write the replies, and reclaim the - /// slot when a hung-up peer's last fid is released. Returns whether this - /// connection still owes work, which the caller or's into the flag that - /// keeps the loop from sleeping. - /// - /// The flush is HERE rather than left to a POLLOUT, so a reply the core - /// produced this frame is on the wire this frame; what the kernel would not - /// take waits for POLLOUT as usual. - pub fn settle(l: *Listener, i: u8, d: fs_service.Drained) bool { - if (comptime !supported) return false; - const c = &l.conns[i]; - if (c.draining) { - // A hung-up connection has no descriptor and therefore no event of - // its own: its orphaned fids are pumped out over successive frames, - // and the loop must stay hot until the pump runs dry. It does run - // dry — the debt is one release per fid. - if (d.count == 0) { - c.draining = false; - return false; - } - return true; - } - l.flush(i); - return d.pending; - } - - /// Every connection down, the socket closed, and the path unlinked. - /// - /// The orphaned fids are NOT pumped here: `deinit` runs when the session is - /// being torn down, and the core it would report the releases to is going - /// with it. `Fs.deinit` makes the same choice about the mount. - pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { - for (0..max_conns) |i| l.drop(@intCast(i)); - if (l.fd >= 0) { - _ = libc.close(l.fd); - l.fd = -1; - var z: [sun_path_len:0]u8 = undefined; - @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); - z[l.path_len] = 0; - _ = libc.unlink(z[0..l.path_len :0]); - } - gpa.destroy(l); - } -}; - -/// `/pardes-9p-.sock`. A name is one path component and nothing -/// clever, for detached/server.zig's `socketPath` reason: a `/` would put the -/// socket somewhere else entirely and a NUL would truncate the address. The -/// buffer is `sun_path`-sized, so a name that does not fit is no address at -/// all rather than a truncated one pointing somewhere else. -pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { - if (name.len == 0) return null; - if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; - return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; -} - -/// Bind, listen, and hand back a listener — or null, which is the same answer -/// for "this platform has no unix sockets", "there is no runtime directory" -/// and "the bind failed". That is `fs_service.start`'s posture and -/// nested.zig's: a transport that will not come up must cost the operator -/// their scripting, never their session. -/// -/// `named` is `Options.fs9`: EMPTY means a bare `--fs9`, so `fallback` names -/// it (the session name in a daemon, this pid anywhere else), and anything -/// else is the name the user gave, which wins — scripts need a path they can -/// predict. -pub fn open(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8) ?*Listener { - if (comptime !supported) return null; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = nested.socketDir(&dir_buf) orelse { - log.warn("--fs9: no runtime directory for the socket", .{}); - return null; - }; - if (!nested.ensureSocketDir(dir)) return null; - const l = gpa.create(Listener) catch return null; - l.* = .{}; - // No sweep of the directory, unlike detached/server.zig's `listen`. That - // sweeper connects to every socket of its OWN prefix to retire dead ones; - // this prefix has no handshake to probe with, so the only stale file worth - // removing is the one this bind collides with, immediately below. - const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { - gpa.destroy(l); - return null; - }; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) { - gpa.destroy(l); - return null; - } - nested.setCloexec(fd); - // `bind` IS the exclusive create, so it and nothing else decides who owns - // a name — detached/server.zig's rule, and its reason: unlinking - // unconditionally is how a second daemon takes a live one's socket away. - // The one case that is not a collision is a session killed rather than - // quit, whose file outlived it, and `alive` is the only thing allowed to - // say so. - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - if (alive(p)) { - log.warn("--fs9: something is already listening on {s}", .{p}); - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - _ = libc.unlink(p); - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - } - // Owner-only, and BEFORE listen(2), which is the first moment anyone could - // connect. The directory is already 0700; this is the second wall, and - // this socket can write into every pane of a live editor. - _ = libc.chmod(p, 0o600); - if (libc.listen(fd, max_conns) != 0) { - _ = libc.close(fd); - gpa.destroy(l); - return null; - } - setNonblock(fd); - l.fd = fd; - l.path_len = p.len; - log.info("--fs9: serving 9P2000 on {s}", .{p}); - return l; -} - -/// Whether a socket file at `path` has a listener behind it. Only ever asked -/// about a bind that failed, and it answers on the CONNECT: a refusal means -/// the file outlived its process and may be unlinked, and anything else — -/// including a success — means somebody is there. -fn alive(path: [:0]const u8) bool { - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied - @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return true; - defer _ = libc.close(fd); - return libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0; -} - -/// Every descriptor here is non-blocking, for detached/server.zig's reason: -/// the core must never park on a peer. Its `setNonblock` is not reused because -/// importing the daemon into a module the tty and GUI shells may also serve -/// from would make a frontend transport a dependency of a filesystem. -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// A dead script must never kill the editor. linux says it per write, darwin -/// once per socket (see `accept`). -const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; - -const testing = std.testing; - -test "the socket name is a third prefix in the shared directory" { - // Asserted rather than described: nested.zig's sweeper unlinks any name - // whose digits name a dead pid, and the detached transport's `vetted` - // accepts only its own prefix. A 9P socket must be invisible to both. - var buf: [sun_path_len]u8 = undefined; - const p = socketPath(&buf, "/run/user/1000", "t9srv").?; - try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p); - try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-")); -} - -test "a name that is not one path component is no address at all" { - var buf: [sun_path_len]u8 = undefined; - try testing.expect(socketPath(&buf, "/run", "") == null); - try testing.expect(socketPath(&buf, "/run", "a/b") == null); - try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); -} - -test "one connection's buffers are sized from the one msize constant" { - // The `Srv` asserts both of these at `init`, where a violation is a panic - // in a live daemon; here it is a build failure instead. - try testing.expect(msize >= ninep.min_msize); - const c: Conn = .{}; - try testing.expectEqual(@as(usize, msize), c.in.len); - try testing.expectEqual(@as(usize, 2 * msize), c.out.len); -} - diff --git a/src/fs_service.zig b/src/fs_service.zig deleted file mode 100644 index b440c2c2..00000000 --- a/src/fs_service.zig +++ /dev/null @@ -1,324 +0,0 @@ -//! `pardes --fs`: what a native HOST has to decide to serve acme's control -//! filesystem. `acmefs.zig` owns the semantics and `fuse.zig` owns the kernel; -//! what is left, and lives here, is three decisions — WHERE to mount (derive a -//! per-session point, or take the one the user named), WHEN to drain (one -//! frame's batch, in the order fuse.zig's two queues require), and WHAT A PANE -//! SHELL IS TOLD about it (`PARDES_FS`/`PARDES_PANE`, exported before the -//! fork). -//! -//! It exists because tty.zig and gui.zig would otherwise each carry the same -//! forty lines through two different loops; the only thing that genuinely -//! differs between them is how a background thread wakes the loop, and that is -//! a function pointer. A session without `--fs` allocates nothing here, starts -//! no thread, and costs one null check per frame. -const std = @import("std"); -const libc = std.c; -const pardes = @import("pardes.zig"); -const fuse = @import("fuse.zig"); - -/// Diagnostics land on a pane's message row, not on stderr: in the tty shell -/// stderr IS the screen (see main.zig's logFn, which drops every scope for -/// exactly that reason). The log line is the `PARDES_LOG=1` copy, where the -/// mount point and the errno name are worth having. -const log = std.log.scoped(.fs); - -// std.c has getenv but neither setter, same as nested.zig. -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn unsetenv(name: [*:0]const u8) c_int; - -/// How many kernel requests one frame will answer before handing the loop back -/// to the renderer. A `find $PARDES_FS` or a script in a `while true` loop can -/// produce them faster than a frame takes, and an uncapped drain would render -/// only when the script paused. Hitting the cap is not a stall: `drain` says so -/// and the caller wakes its own loop, so the batch continues on the next pass -/// with one frame drawn in between. -const max_batch = 64; - -/// WHAT A TRANSPORT IS, to this file: three functions and a pointer. -/// -/// `drain` and `step` below never asked a `*fuse.Fs` for anything else — -/// `retry()`, `next()` and `reply()` are the whole of it — so the concrete -/// pointer was a coupling that bought nothing and forbade a second answer. -/// Naming the three makes the seam a thing a reader can see, and makes a 9P -/// listener beside the mount a matter of writing one more implementation -/// rather than of teaching this file about it. -/// -/// It is deliberately NOT a Zig interface with `anytype`: `drain` is ONE -/// function reached from four call sites (tty.zig, gui.zig twice, and the -/// daemon), and the second transport this seam exists for is chosen at RUN -/// time, so it has to be a value with a runtime type — which is a vtable, the -/// same shape and the same reasoning as `host.VTable`. Nothing holds a -/// `Transport` across a frame: every caller builds one inline from whatever it -/// has, which is why the thunks matter and the struct does not. -/// -/// The ORDER contract stays where it was, in `drain`, because it belongs to -/// the caller rather than to any implementor: `retry()` to null first, then -/// `next()` to null. An implementation with no parking answers `retry` null -/// forever and loses nothing. -pub const Transport = struct { - ctx: *anyopaque, - vtable: *const VTable, - - pub const VTable = struct { - /// The oldest parked request that is worth offering again, or null when - /// the round is over. Null also RESETS the round — see `drain`. - retry: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// The next request off the wire, or null when there is nothing more. - /// That null is also the acknowledgement some transports owe a poller, - /// so a caller must reach it rather than stopping early. - next: *const fn (ctx: *anyopaque) ?pardes.acmefs.Req, - /// Answer one request. `bytes` is borrowed for the duration of the - /// call only. A `.again` status is the transport's business, not the - /// caller's: it re-parks the request itself. - reply: *const fn (ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void, - }; - - pub fn retry(t: Transport) ?pardes.acmefs.Req { - return t.vtable.retry(t.ctx); - } - - pub fn next(t: Transport) ?pardes.acmefs.Req { - return t.vtable.next(t.ctx); - } - - pub fn reply(t: Transport, r: *const pardes.acmefs.Reply, bytes: []const u8) void { - t.vtable.reply(t.ctx, r, bytes); - } -}; - -/// Where per-session mounts live: `$XDG_RUNTIME_DIR/pardes` else -/// `~/.local/state/pardes`, and `/` is this session's mount point. -/// -/// NOT `nested.socketDir`, though it answers a related question. That one -/// returns `$XDG_RUNTIME_DIR` itself, because a socket is a FILE whose name -/// (`pardes-.sock`) already namespaces it. A mount point is a DIRECTORY -/// per pid, and `fuse.sweepStale` unmounts and removes every `` entry -/// it finds — so it needs a parent that contains nothing but our mounts, which -/// under `$XDG_RUNTIME_DIR` means one more level. The HOME fallback already has -/// that level, which is why the two strings coincide there and only there. -/// The two strings are parameters rather than `getenv` calls so the tests below -/// need not mutate the process environment. That is not fastidiousness: a test -/// binary shares one environ, and unsetting HOME here once took down an -/// unrelated subprocess test three files away. -fn parentFrom(buf: *[std.fs.max_path_bytes:0]u8, xdg: ?[]const u8, home: ?[]const u8) ?[:0]const u8 { - if (xdg) |x| return std.fmt.bufPrintSentinel(buf, "{s}/pardes", .{x}, 0) catch null; - const h = home orelse return null; - return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{h}, 0) catch null; -} - -fn envSlice(name: [*:0]const u8) ?[]const u8 { - return if (libc.getenv(name)) |v| std.mem.span(v) else null; -} - -fn parentDir(buf: *[std.fs.max_path_bytes:0]u8) ?[:0]const u8 { - return parentFrom(buf, envSlice("XDG_RUNTIME_DIR"), envSlice("HOME")); -} - -/// The mount point itself, from `Options.fs`: EMPTY means a bare `--fs`, so -/// derive `/`, and anything else is the `--fs=` the user -/// named, which wins verbatim — scripts and the snapshot harness need a name -/// they can predict. -/// -/// A named point must be absolute for the reason fuse.zig gives: the path is -/// handed to a setuid helper that resolves it against its OWN cwd, so a -/// relative one names somewhere else. Passing it through unresolved rather than -/// rooting it here keeps that one rule in one place; `Fs.mount` returns -/// `error.MountPathNotAbsolute`. -fn mountPoint(buf: *[std.fs.max_path_bytes:0]u8, named: []const u8, parent: ?[]const u8) ?[:0]const u8 { - if (named.len != 0) return std.fmt.bufPrintSentinel(buf, "{s}", .{named}, 0) catch null; - const dir = parent orelse return null; - // unsigned: {d} prints a leading '+' for a positive SIGNED int - return std.fmt.bufPrintSentinel(buf, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }, 0) catch null; -} - -/// Sweep, derive, mount. Null when the session did not ask for a filesystem — -/// and also when it asked and the mount failed, which is deliberately the same -/// answer: a missing `fuse3`, a `user_allow_other`-less config or a kernel -/// without FUSE must cost the user their scripting, never their session. The -/// failure is reported once, on pane 0's message row, and everything else runs. -/// -/// Call after the core exists and before the first frame: the mount is live the -/// moment it returns, so a script racing startup finds a filesystem whose panes -/// are already there. -pub fn start(gpa: std.mem.Allocator, core: *pardes.Pardes) ?*fuse.Fs { - const named = core.opts.fs orelse return null; - var parent_buf: [std.fs.max_path_bytes:0]u8 = undefined; - const parent = parentDir(&parent_buf); - var buf: [std.fs.max_path_bytes:0]u8 = undefined; - const point = mountPoint(&buf, named, parent) orelse { - core.reportError(0, "fs mount", error.NoRuntimeDirectory); - return null; - }; - // Both of these are about a point we DERIVED. A `--fs=` the user named - // is not a directory we are entitled to unmount other things out of, its - // siblings are not ours to guess about, and it is not ours to remove on the - // way out either — `owns_dir` is what keeps `Fs.deinit` from rmdir'ing a - // directory the user made. - const derived = named.len == 0; - if (derived) if (parent) |dir| fuse.sweepStale(dir); - const fs = fuse.Fs.mount(gpa, .{ .mount = point, .owns_dir = derived }) catch |err| { - log.warn("--fs: cannot mount at {s}: {t}", .{ point, err }); - core.reportError(0, "fs mount", err); - return null; - }; - log.info("--fs: serving {s}", .{point}); - return fs; -} - -/// Start the one background thread, if there is a filesystem to start it for. -/// It waits for POLLIN on `/dev/fuse` and calls `wake(ctx)` — nothing else; it -/// never touches the core, the descriptor's data, or a request. Both hosts pass -/// a one-line callback that posts their own wake event, which is the ONLY thing -/// that differs between them here. -/// -/// A thread that will not spawn is not a filesystem that will not work: the -/// frame poll drains the same requests either way, so the loss is wake latency -/// (a script waits for the next event to arrive from anywhere) and the session -/// is not worth failing over it. That is also the documented no-parallelism -/// backend: skip this call entirely and everything still works. -pub fn wake(fs: ?*fuse.Fs, ctx: ?*anyopaque, callback: *const fn (?*anyopaque) void) void { - const f = fs orelse return; - f.wakeThread(ctx, callback) catch |err| - log.warn("--fs: no poll thread ({t}); draining once per frame instead", .{err}); -} - -/// What one frame's worth of filesystem work amounted to. Two separate facts, -/// because the two hosts need different ones: an interactive loop asks whether -/// to re-arm itself, while the headless grid harness asks whether anything -/// happened at all — its contract is one frame per event, and a request that -/// changed a pane IS an event. -pub const Drained = struct { - /// Requests answered, parked retries included. - count: usize = 0, - /// The cap stopped the batch with requests still waiting in the kernel. - pending: bool = false, -}; - -/// One frame's worth of filesystem work. -/// -/// The two loops are both to null and in this order, which is the TRANSPORT -/// contract rather than a preference — stated here because it belongs to the -/// caller, and every implementor inherits it: -/// -/// - `retry()`'s null ENDS AND RESETS the round, so a caller that took one -/// parked request per frame would leave the second-oldest blocked reader -/// waiting 32 frames. The round is bounded by the park table, so it needs -/// no cap of its own. -/// - `next()`'s null is what acknowledges the drain to whatever is waiting on -/// the descriptor. For the FUSE mount that is a poll thread, and the -/// handshake is what stops a level-triggered `poll()` from spinning a core; -/// which is why `pending` has to keep the loop hot: no ack has been sent, -/// so nothing else will wake us. -pub fn drain(t: Transport, core: *pardes.Pardes) Drained { - var d: Drained = .{}; - while (t.retry()) |req| { - step(t, core, req); - d.count += 1; - } - while (d.count < max_batch) { - const req = t.next() orelse return d; - step(t, core, req); - d.count += 1; - } - d.pending = true; - return d; -} - -/// One request, one answer, and nothing in between: `req.data` borrows storage -/// the next `next()` overwrites, and the `.fs_reply` this emits is drained -/// before the loop can move on — so the borrow window is a single step, exactly -/// as the design contract requires. The reply normally reaches the transport -/// through the host's `push_fs_reply`, because the payload bytes are resolved -/// by `pardes.fsPayload` inside `perform` and are only valid there. -/// -/// The exception is the `if` at the end. The core's effect ring is bounded and -/// `emit` DROPS on overflow, which for every other effect costs a repaint and -/// for this one costs a foreign process: an unanswered FUSE request leaves its -/// writer in uninterruptible sleep and its park slot used forever, and 32 of -/// those make the whole mount answer EAGAIN. One `ctl` write reaches the cap -/// (`put` emits a `.save_file` per line). So this loop, which is the only place -/// that knows a request is outstanding, watches the effects it performs for the -/// answer and invents an EIO when none came. -fn step(t: Transport, core: *pardes.Pardes, req: pardes.acmefs.Req) void { - core.update(.{ .fs_req = req }); - var answered = false; - while (core.nextEffect()) |e| { - if (e == .fs_reply and e.fs_reply.tag == req.tag) answered = true; - core.perform(e); - } - if (!answered) { - const eio = pardes.acmefs.Reply.fail(req.tag, pardes.acmefs.E.IO); - t.reply(&eio, ""); - } -} - -/// What a pane shell is told about the filesystem: `PARDES_FS` is the mount and -/// `PARDES_PANE` is this pane's serial, so a script run inside a pane addresses -/// its own window with no arguments. That pair is acme's `winid` (exec.c), and -/// the serial rather than the slot index because slots are reused and serials -/// never are — `$PARDES_FS/$PARDES_PANE/body` must not start naming somebody -/// else's pane after a close. -/// -/// Exported in the PARENT, immediately before the fork, and this is the one -/// place pardes cannot copy acme. acme calls `putenv` in the child, which is -/// safe there because `rfork(RFENVG)` has just given that child a private -/// environment group. A Linux fork has no such thing, and `setenv` between fork -/// and exec can deadlock on an allocator lock some other thread held at fork -/// time — the same rule that already forces `shell_bin.resolve` above the fork -/// in both hosts. The cost is that pardes's own environ carries the -/// last-spawned pane's number; nothing in pardes reads it, and a subprocess -/// that inherits it was spawned on behalf of a pane anyway. -/// -/// With no filesystem the pair is REMOVED rather than left alone. A pardes -/// started inside a pardes that does serve one inherits both variables from its -/// parent's pane shell, and a session with no mount of its own must not hand -/// its panes an address that resolves to a window in someone else's session. -pub fn exportPaneEnv(fs: ?*const fuse.Fs, serial: u32) void { - const f = fs orelse { - _ = unsetenv("PARDES_FS"); - _ = unsetenv("PARDES_PANE"); - return; - }; - _ = setenv("PARDES_FS", f.path.ptr, 1); - var buf: [16:0]u8 = undefined; - const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch return; - _ = setenv("PARDES_PANE", id.ptr, 1); -} - -const testing = std.testing; - -test "the mount point is one level below a per-user parent, named by our pid" { - // $XDG_RUNTIME_DIR is shared with every other program in the session, so - // the mounts need a `pardes/` of their own under it — the level - // nested.socketDir does not have, and the reason this is not that function. - // Asserted rather than merely described, because `fuse.sweepStale` unmounts - // and removes every `` entry in whatever directory it is handed. - var parent: [std.fs.max_path_bytes:0]u8 = undefined; - const dir = parentFrom(&parent, "/run/user/1000", "/home/tester").?; - try testing.expectEqualStrings("/run/user/1000/pardes", dir); - var buf: [std.fs.max_path_bytes:0]u8 = undefined; - var expect: [std.fs.max_path_bytes]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&expect, "{s}/{d}", .{ dir, @as(u32, @intCast(libc.getpid())) }), - mountPoint(&buf, "", dir).?, - ); -} - -test "no XDG_RUNTIME_DIR falls back to the home state directory, which has the level already" { - var parent: [std.fs.max_path_bytes:0]u8 = undefined; - try testing.expectEqualStrings( - "/home/tester/.local/state/pardes", - parentFrom(&parent, null, "/home/tester").?, - ); -} - -test "a session with no filesystem removes an inherited address rather than passing it on" { - // PARDES_FS/PARDES_PANE are ours alone, and this leaves them the way an - // --fs-less session leaves them: absent. Nothing else in the test binary - // reads either name, which is why this is the one env-touching test here. - _ = setenv("PARDES_FS", "/run/user/1000/pardes/999", 1); - _ = setenv("PARDES_PANE", "7", 1); - exportPaneEnv(null, 3); - try testing.expect(libc.getenv("PARDES_FS") == null); - try testing.expect(libc.getenv("PARDES_PANE") == null); -} diff --git a/src/fuse.zig b/src/fuse.zig deleted file mode 100644 index 3bd263bd..00000000 --- a/src/fuse.zig +++ /dev/null @@ -1,2749 +0,0 @@ -//! The `/dev/fuse` transport for pardes's acme control filesystem: wire codec, -//! mount and unmount through `fusermount3`, one `poll()` thread, and the park -//! table that turns acme's blocking `event` read into "ask me again later". -//! -//! Raw protocol, no libfuse. libfuse is a thread pool, a request dispatcher and -//! a session lifetime — three things pardes already has and would have to fight. -//! What is left once those are removed is a struct layout and a read/write loop, -//! which is this file. It links nothing; the only external program it runs is -//! the setuid `fusermount3` helper, because an unprivileged process cannot -//! `mount(2)` in the initial user namespace and that helper exists precisely to -//! hand back a `/dev/fuse` descriptor for a mount it made on our behalf. -//! -//! The whole file is one side of a strict division of labour: -//! -//! - `acmefs.zig` owns the semantics and knows nothing about FUSE. It speaks -//! `Req`/`Reply` and never blocks. -//! - this file owns the kernel's opinions and knows nothing about panes. It -//! answers, in place, every request the core has no business seeing (INIT, -//! FORGET, INTERRUPT, DESTROY and the whole ENOSYS family), and translates -//! the eleven that remain. -//! - the host loop (tty/gui) owns the ordering: `retry()` to null, `next()` -//! to null, one `update()` per request, effects drained in between. -//! -//! THREADING. The main thread owns the descriptor for read and for write. The -//! poll thread never touches its data, never sees a `Req`, and never calls into -//! the core; it waits for POLLIN, calls the host's wake callback, and then -//! blocks until the main thread has drained. That last handshake is not -//! decoration: `poll()` is level triggered, so a poller that re-polls -//! immediately would spin a core at 100% for as long as one unanswered request -//! sits in the kernel queue. A host with no threads at all skips `wakeThread` -//! and drains from its frame poll; it loses wake latency and nothing else. -//! -//! BLOCKING. A FUSE server blocks a reader by simply not answering, and that is -//! the one and only way (the kernel gives no meaning to an EAGAIN reply). So -//! `Status.again` means "held": the request moves into the park table with its -//! bytes copied out of the read buffer, and `retry()` offers it back once per -//! frame until the core has something to say. Two obligations come with that: -//! -//! 1. a SIGKILLed reader whose request is never answered ends in -//! *uninterruptible* sleep (`fuse_dev`'s final `wait_event` is not -//! killable), so it survives its own kill until we reply. FUSE_INTERRUPT -//! is the escape hatch and is honoured below. -//! 2. teardown must answer everything still parked, and must abort the -//! connection by closing the descriptor before unmounting, or a reader -//! that raced the shutdown is stuck in D state with nobody left to wake -//! it. -//! -//! Linux only, guarded the way `file_watch.zig` guards inotify: every entry -//! point returns the inert answer off Linux, so a macOS or web build compiles -//! and mounts nothing. Only `mount()` can create an `Fs`, so off Linux no other -//! function in this file is ever reached. -//! -//! Verified against `/usr/include/linux/fuse.h` (7.45) and `fs/fuse/{dev,inode, -//! file,dir,readdir}.c`; the comptime size assertions below turn a header drift -//! into a compile error rather than a wedged mount nobody can unmount. -const std = @import("std"); -const builtin = @import("builtin"); -const libc = std.c; -const linux = std.os.linux; -const acmefs = @import("acmefs.zig"); -/// Only for `Transport`, the three-function shape this mount presents to the -/// host loop. This IS a cycle — `fs_service` imports this file back for -/// `Fs.mount`, `sweepStale` and `exportPaneEnv`, and still names `*Fs` in three -/// of its own signatures — and Zig accepts it because imports are analysed -/// lazily. What the seam removed is `drain`'s dependency on the concrete type, -/// not the file's dependency on this one. Do not read it as more than that. -const fs_service = @import("fs_service.zig"); - -/// Everything below the mount is Linux kernel ABI. Off Linux the module still -/// compiles (it is imported by the shared native shell) and does nothing. -const supported = builtin.os.tag == .linux; - -// --------------------------------------------------------------------------- -// wire protocol -// --------------------------------------------------------------------------- - -/// The protocol version this server speaks. A mismatch in the *major* aborts -/// the connection outright (`fuse_init_finish`: `arg->major != -/// FUSE_KERNEL_VERSION` -> `ok = false` -> the mount is dead on arrival), so -/// there is nothing to negotiate there. -const kernel_version: u32 = 7; - -/// The highest minor these structs were checked against (see the module -/// header). The INIT reply carries `@min(kernel_minor, what the kernel -/// offered)`: `fuse_init_finish` stores our number as `fc->minor`, and the -/// kernel then sizes the replies it reads back from us by it (the -/// `FUSE_COMPAT_*_SIZE` family in `fs/fuse/`), so echoing a *newer* kernel's -/// minor promises reply fields these structs do not have. Capping costs -/// nothing: with `flags = 0` no feature depends on the number. -const kernel_minor: u32 = 45; - -/// `fuse_dev_do_read` refuses to hand over a request when the server's read -/// buffer is smaller than this, and answers the *client* EIO instead: every -/// syscall through the mount fails and nothing says why. -const min_read_buffer: usize = 8192; - -/// `FUSE_REC_ALIGN`. A dirent record that is not a multiple of 8 desynchronises -/// the kernel's parse of the rest of the reply, so one bad name turns the whole -/// directory into garbage rather than into an error. -const rec_align: usize = 8; - -/// `FUSE_NAME_OFFSET` — the fixed part of a `fuse_dirent`, before the name. -const dirent_name_offset: usize = @sizeOf(fuse_dirent); - -fn recAlign(n: usize) usize { - return (n + rec_align - 1) & ~(rec_align - 1); -} - -/// The subset of `enum fuse_opcode` this server can receive. Non-exhaustive on -/// purpose: a newer kernel adds opcodes, and `@enumFromInt` of an unlisted -/// value into an exhaustive enum is undefined behaviour — the one bug in a -/// protocol decoder that cannot be diagnosed from the outside. -const Opcode = enum(u32) { - lookup = 1, - forget = 2, - getattr = 3, - setattr = 4, - readlink = 5, - symlink = 6, - mknod = 8, - mkdir = 9, - unlink = 10, - rmdir = 11, - rename = 12, - link = 13, - open = 14, - read = 15, - write = 16, - statfs = 17, - release = 18, - fsync = 20, - setxattr = 21, - getxattr = 22, - listxattr = 23, - removexattr = 24, - flush = 25, - init = 26, - opendir = 27, - readdir = 28, - releasedir = 29, - fsyncdir = 30, - getlk = 31, - setlk = 32, - setlkw = 33, - access = 34, - create = 35, - interrupt = 36, - bmap = 37, - destroy = 38, - ioctl = 39, - poll = 40, - notify_reply = 41, - batch_forget = 42, - fallocate = 43, - readdirplus = 44, - rename2 = 45, - lseek = 46, - copy_file_range = 47, - setupmapping = 48, - removemapping = 49, - syncfs = 50, - tmpfile = 51, - statx = 52, - copy_file_range_64 = 53, - _, -}; - -/// `FATTR_SIZE`. The only setattr bit this filesystem reads: without -/// `FUSE_ATOMIC_O_TRUNC` (which `flags = 0` deliberately does not negotiate) -/// the kernel strips `O_TRUNC` from the OPEN and issues a separate -/// `SETATTR(size = 0)`, so this bit *is* how `> file` reaches the core. -const FATTR_SIZE: u32 = 1 << 3; - -/// `FUSE_GETATTR_FH` — says the `fh` field of `fuse_getattr_in` is meaningful. -/// Reading `fh` without checking it hands the core a stale handle from an -/// unrelated open. -const FUSE_GETATTR_FH: u32 = 1 << 0; - -/// `FOPEN_DIRECT_IO`. Without it the kernel serves reads out of the page cache -/// and coalesces them, which for this filesystem is wrong in both directions: -/// a second `cat` of `index` would return the first one's bytes, and a blocking -/// `event` read would never reach us at all. -const FOPEN_DIRECT_IO: u32 = 1 << 0; - -const fuse_in_header = extern struct { - len: u32, - opcode: u32, - unique: u64, - nodeid: u64, - uid: u32, - gid: u32, - pid: u32, - total_extlen: u16, - padding: u16, -}; - -const fuse_out_header = extern struct { - len: u32, - @"error": i32, - unique: u64, -}; - -const fuse_init_in = extern struct { - major: u32, - minor: u32, - max_readahead: u32, - flags: u32, - flags2: u32, - unused: [11]u32, -}; - -const fuse_init_out = extern struct { - major: u32, - minor: u32, - max_readahead: u32, - flags: u32, - max_background: u16, - congestion_threshold: u16, - max_write: u32, - time_gran: u32, - max_pages: u16, - map_alignment: u16, - flags2: u32, - max_stack_depth: u32, - request_timeout: u16, - unused: [11]u16, -}; - -const fuse_attr = extern struct { - ino: u64, - size: u64, - blocks: u64, - atime: u64, - mtime: u64, - ctime: u64, - atimensec: u32, - mtimensec: u32, - ctimensec: u32, - mode: u32, - nlink: u32, - uid: u32, - gid: u32, - rdev: u32, - blksize: u32, - flags: u32, -}; - -const fuse_entry_out = extern struct { - nodeid: u64, - generation: u64, - entry_valid: u64, - attr_valid: u64, - entry_valid_nsec: u32, - attr_valid_nsec: u32, - attr: fuse_attr, -}; - -const fuse_attr_out = extern struct { - attr_valid: u64, - attr_valid_nsec: u32, - dummy: u32, - attr: fuse_attr, -}; - -const fuse_getattr_in = extern struct { - getattr_flags: u32, - dummy: u32, - fh: u64, -}; - -const fuse_setattr_in = extern struct { - valid: u32, - padding: u32, - fh: u64, - size: u64, - lock_owner: u64, - atime: u64, - mtime: u64, - ctime: u64, - atimensec: u32, - mtimensec: u32, - ctimensec: u32, - mode: u32, - unused4: u32, - uid: u32, - gid: u32, - unused5: u32, -}; - -const fuse_open_in = extern struct { - flags: u32, - open_flags: u32, -}; - -const fuse_open_out = extern struct { - fh: u64, - open_flags: u32, - backing_id: i32, -}; - -const fuse_read_in = extern struct { - fh: u64, - offset: u64, - size: u32, - read_flags: u32, - lock_owner: u64, - flags: u32, - padding: u32, -}; - -const fuse_write_in = extern struct { - fh: u64, - offset: u64, - size: u32, - write_flags: u32, - lock_owner: u64, - flags: u32, - padding: u32, -}; - -const fuse_write_out = extern struct { - size: u32, - padding: u32, -}; - -const fuse_release_in = extern struct { - fh: u64, - flags: u32, - release_flags: u32, - lock_owner: u64, -}; - -const fuse_flush_in = extern struct { - fh: u64, - unused: u32, - padding: u32, - lock_owner: u64, -}; - -const fuse_forget_in = extern struct { - nlookup: u64, -}; - -const fuse_batch_forget_in = extern struct { - count: u32, - dummy: u32, -}; - -const fuse_interrupt_in = extern struct { - unique: u64, -}; - -const fuse_kstatfs = extern struct { - blocks: u64, - bfree: u64, - bavail: u64, - files: u64, - ffree: u64, - bsize: u32, - namelen: u32, - frsize: u32, - padding: u32, - spare: [6]u32, -}; - -const fuse_statfs_out = extern struct { - st: fuse_kstatfs, -}; - -/// The `name` array is flexible in C and therefore absent here; this struct IS -/// `FUSE_NAME_OFFSET`, and `dirent_name_offset` is taken from its size so the -/// encoder and the kernel cannot disagree about where a name starts. -const fuse_dirent = extern struct { - ino: u64, - off: u64, - namelen: u32, - type: u32, -}; - -/// `DT_*` from `linux/dirent.h`, as `fuse_dirent.type` wants them. -const DT_DIR: u32 = 4; -const DT_REG: u32 = 8; - -/// `S_IFMT` bits. `Reply.Attr.mode` carries permissions only, so the format -/// nibble is ours to add; a `fuse_attr.mode` with no format bits is a file of -/// no type and `stat(2)` through the mount returns something no tool expects. -const S_IFDIR: u32 = 0o040000; -const S_IFREG: u32 = 0o100000; - -// A drifted header is a mount that hangs with no diagnostic, so every struct -// on the wire asserts its size here. These numbers are `sizeof` from -// /usr/include/linux/fuse.h at FUSE_KERNEL_MINOR_VERSION 45; they are frozen -// ABI and are not allowed to change under us silently. -comptime { - std.debug.assert(@sizeOf(fuse_in_header) == 40); - std.debug.assert(@sizeOf(fuse_out_header) == 16); - std.debug.assert(@sizeOf(fuse_init_in) == 64); - std.debug.assert(@sizeOf(fuse_init_out) == 64); - std.debug.assert(@sizeOf(fuse_attr) == 88); - std.debug.assert(@sizeOf(fuse_entry_out) == 128); - std.debug.assert(@sizeOf(fuse_attr_out) == 104); - std.debug.assert(@sizeOf(fuse_getattr_in) == 16); - std.debug.assert(@sizeOf(fuse_setattr_in) == 88); - std.debug.assert(@sizeOf(fuse_open_in) == 8); - std.debug.assert(@sizeOf(fuse_open_out) == 16); - std.debug.assert(@sizeOf(fuse_read_in) == 40); - std.debug.assert(@sizeOf(fuse_write_in) == 40); - std.debug.assert(@sizeOf(fuse_write_out) == 8); - std.debug.assert(@sizeOf(fuse_release_in) == 24); - std.debug.assert(@sizeOf(fuse_flush_in) == 24); - std.debug.assert(@sizeOf(fuse_forget_in) == 8); - std.debug.assert(@sizeOf(fuse_batch_forget_in) == 8); - std.debug.assert(@sizeOf(fuse_interrupt_in) == 8); - std.debug.assert(@sizeOf(fuse_kstatfs) == 80); - std.debug.assert(@sizeOf(fuse_statfs_out) == 80); - std.debug.assert(@sizeOf(fuse_dirent) == 24); - // The one field offset the codec depends on beyond struct sizes: the body - // of every request starts here, and 40 is a multiple of 8, which is what - // lets the parse point a struct at the read buffer instead of copying. - std.debug.assert(@sizeOf(fuse_in_header) % rec_align == 0); -} - -// --------------------------------------------------------------------------- -// the neutral readdir staging format -// --------------------------------------------------------------------------- - -/// How `acmefs` hands a directory listing to this file. The core is protocol -/// neutral by design, so it must not stage `fuse_dirent`s: those carry an -/// alignment rule, a cookie rule and a `DT_*` table that are the kernel's -/// business, not the editor's. It stages this instead, packed and repeated, -/// little endian, into `State.out`: -/// -/// node: u64 the acmefs node id of the entry, never 0 (see below) -/// kind: u8 0 = regular file, 1 = directory -/// namelen: u8 1..255, never 0 -/// name: [namelen]u8 -/// -/// `node` travels so that the `d_ino` a `getdents64` sees is the same number a -/// later `stat` reports. Synthesising one here instead would make `find -inum` -/// and every hardlink-detecting tool lie about this filesystem. -/// -/// `node` is never 0. It used to be, for the entries under `new/`: those name -/// panes that do not exist, because acme creates the pane when the name is -/// LOOKED UP. `new/` now stages nothing at all — every name in it is a -/// *creating* lookup, so any tool that stats what a readdir reported (`ls -l`, -/// `find`, tab completion) would make one pane per entry — which is why there -/// is no longer a sentinel `d_ino` for an unresolved name on the wire. -/// -/// The core stages entries starting at index `req.off` (the cookie the kernel -/// echoed back) in a stable order. This encoder assigns cookie `off = req.off + -/// n + 1` to the nth entry it emits, and may emit only a *prefix* of what was -/// staged when the kernel's requested `size` runs out — the remainder comes -/// back as another readdir at the higher cookie, so staging has to be -/// idempotent per cookie rather than a stream. Zero staged bytes means EOF; it -/// is not an error, and the kernel stops asking. -/// -/// No `.` or `..`: the kernel synthesises neither and needs neither, and a -/// filesystem that emits them has to answer `LOOKUP("..")` too. -pub const dirent_stage_prefix = 10; - -/// Encode staged entries into kernel `fuse_dirent` records. Returns the bytes -/// written to `out`. Pure: this is where the alignment and cookie rules live, -/// and it is tested directly. -fn encodeDirents(out: []u8, staged: []const u8, cookie: u64) usize { - var in: usize = 0; - var w: usize = 0; - var n: u64 = 0; - while (in + dirent_stage_prefix <= staged.len) { - const node = std.mem.readInt(u64, staged[in..][0..8], .little); - const kind = staged[in + 8]; - const namelen: usize = staged[in + 9]; - // A zero name length would make the record self-referential (the - // kernel would parse the padding as the next entry), and a truncated - // record means the core staged something we cannot read. Stop rather - // than guess: a short reply is a legal readdir, a malformed one is not. - if (namelen == 0 or in + dirent_stage_prefix + namelen > staged.len) break; - const name = staged[in + dirent_stage_prefix ..][0..namelen]; - const record = recAlign(dirent_name_offset + namelen); - if (w + record > out.len) break; - - // Written field by field rather than through a struct pointer: `out` - // is a caller's slice of unknown alignment, and one @alignCast that is - // wrong here is a misaligned store into a kernel-bound buffer. - std.mem.writeInt(u64, out[w..][0..8], node, .little); - std.mem.writeInt(u64, out[w + 8 ..][0..8], cookie + n + 1, .little); - std.mem.writeInt(u32, out[w + 16 ..][0..4], @intCast(namelen), .little); - std.mem.writeInt(u32, out[w + 20 ..][0..4], if (kind == 1) DT_DIR else DT_REG, .little); - @memcpy(out[w + dirent_name_offset ..][0..namelen], name); - // The kernel never shows the padding to anyone, but zeroing it keeps - // the wire deterministic, which is what the encoder test asserts on. - @memset(out[w + dirent_name_offset + namelen ..][0 .. record - dirent_name_offset - namelen], 0); - - in += dirent_stage_prefix + namelen; - w += record; - n += 1; - } - return w; -} - -// --------------------------------------------------------------------------- -// fusermount3 -// --------------------------------------------------------------------------- - -/// The environment variable `fusermount3` reads to find the socket it must send -/// the `/dev/fuse` descriptor back over. Spelled with the leading underscore in -/// libfuse (`FUSE_COMMFD_ENV`); it is a private contract between the two -/// programs, not a user knob. -const commfd_env = "_FUSE_COMMFD"; - -/// Where the helper might be. Arch puts it in /usr/bin with /usr/sbin a symlink -/// to it, Debian derivatives use /usr/bin, and a machine with only libfuse2 -/// installed spells it without the 3 — that binary speaks the same -/// socketpair/SCM_RIGHTS protocol, so it is a real fallback and not a guess. -/// Searched by absolute path rather than through PATH because the thing being -/// executed is setuid root: PATH is attacker-influenced input. -const fusermount_paths = [_][:0]const u8{ - "/usr/bin/fusermount3", - "/usr/sbin/fusermount3", - "/bin/fusermount3", - "/sbin/fusermount3", - "/usr/local/bin/fusermount3", - "/usr/bin/fusermount", - "/usr/sbin/fusermount", - "/bin/fusermount", -}; - -/// The `-o` string. Every option here is a deliberate refusal: -/// -/// - `fsname`/`subtype` are cosmetic but load bearing: they are what `mount`, -/// `df` and `/proc/self/mountinfo` show, and an unnamed fuse mount in a bug -/// report is indistinguishable from anyone else's. -/// - `nosuid,nodev` are what fusermount3 forces anyway; naming them keeps the -/// intent in the source rather than in someone else's default. -/// - NOT `allow_other`: it needs `user_allow_other` in /etc/fuse.conf, which -/// is commented out on a stock Arch install, and asking for it makes -/// fusermount3 fail the whole mount instead of ignoring the option. It -/// would also be wrong — this filesystem executes text on write. -/// - NOT `default_permissions`: with it the kernel enforces the mode bits we -/// report, which sounds like a free wall but moves access control from the -/// core (which knows that `cons` is write-only) into a mode field, so a -/// wrong nibble in a table becomes an EACCES nobody can explain. Same -/// reason INIT negotiates no flags: fewer kernel behaviours to honour. -fn mountOpts(buf: *[128:0]u8) [:0]const u8 { - return std.fmt.bufPrintSentinel(buf, "fsname=pardes,subtype=pardes,nosuid,nodev", .{}, 0) catch unreachable; -} - -/// `_FUSE_COMMFD=`, the child's end of the socketpair by number. libfuse -/// passes the descriptor this way rather than on the command line because -/// fusermount3 is setuid: its argv is world readable through /proc, its -/// environment is not. -fn commfdEnv(buf: *[32:0]u8, fd: c_int) [:0]const u8 { - return std.fmt.bufPrintSentinel(buf, commfd_env ++ "={d}", .{fd}, 0) catch unreachable; -} - -/// `fusermount3 -o -- `. The `--` is not optional: a -/// mountpoint that begins with a dash would otherwise be parsed as a flag by a -/// setuid program. -fn mountArgv( - argv: *[6:null]?[*:0]const u8, - prog: [*:0]const u8, - opts: [*:0]const u8, - mountpoint: [*:0]const u8, -) void { - argv.* = .{ prog, "-o", opts, "--", mountpoint, null }; -} - -/// `fusermount3 -u -q -z -- `. Lazy (`-z`) because the mount may -/// still have an open descriptor on it — a pane shell that inherited a cwd -/// inside the mount, say — and a non-lazy unmount would fail with EBUSY and -/// leave the mount behind for good. Quiet (`-q`) because the common case at -/// exit is a mount the kernel already tore down, and its complaint would be the -/// last thing on the user's terminal. -fn unmountArgv(argv: *[7:null]?[*:0]const u8, prog: [*:0]const u8, mountpoint: [*:0]const u8) void { - argv.* = .{ prog, "-u", "-q", "-z", "--", mountpoint, null }; -} - -/// CMSG_ALIGN/CMSG_LEN/CMSG_SPACE. Only ever evaluated on the Linux path, -/// where the alignment is `sizeof(size_t)`; other platforms align control -/// messages to 4 and would need their own numbers. -fn cmsgAlign(n: usize) usize { - const a: usize = @alignOf(usize); - return (n + a - 1) & ~(a - 1); -} -fn cmsgLen(n: usize) usize { - return cmsgAlign(@sizeOf(libc.cmsghdr)) + n; -} -fn cmsgSpace(n: usize) usize { - return cmsgAlign(@sizeOf(libc.cmsghdr)) + cmsgAlign(n); -} - -/// Build the child's environment: ours, plus `_FUSE_COMMFD`, minus any -/// `_FUSE_COMMFD` we inherited. The subtraction matters — `getenv` returns the -/// *first* match, so an inherited stale entry (pardes launched from inside -/// something that mounts) would win over the one we just appended and -/// fusermount3 would send the descriptor to a closed socket. -fn buildEnv(gpa: std.mem.Allocator, commfd: [:0]const u8) ![]?[*:0]const u8 { - var count: usize = 0; - while (libc.environ[count] != null) count += 1; - const env = try gpa.alloc(?[*:0]const u8, count + 2); - var n: usize = 0; - for (0..count) |i| { - const entry = libc.environ[i].?; - if (std.mem.startsWith(u8, std.mem.span(entry), commfd_env ++ "=")) continue; - env[n] = entry; - n += 1; - } - env[n] = commfd.ptr; - env[n + 1] = null; - return env[0 .. n + 2]; -} - -/// Resolve the helper once, by absolute path. Doing it in the parent rather -/// than by chaining execve attempts in the child keeps `argv[0]` honest (it is -/// what `ps` and fusermount3's own diagnostics print) and turns "fuse3 is not -/// installed" into its own error instead of an exit status. -fn findFusermount() ?[:0]const u8 { - for (fusermount_paths) |candidate| { - if (libc.access(candidate.ptr, libc.X_OK) == 0) return candidate; - } - return null; -} - -/// fork + execve the helper and wait for it. Not `std.process.Child`: that has -/// no way to hand a child an arbitrary descriptor, and the entire protocol here -/// is "the child writes to descriptor N". Everything the child does before -/// execve is async-signal-safe (close, execve, _exit) because the parent may -/// well be multithreaded by the time this runs. -fn spawnHelper( - prog: [*:0]const u8, - argv: [*:null]const ?[*:0]const u8, - envp: [*:null]const ?[*:0]const u8, - close_in_child: c_int, -) !u8 { - const pid = libc.fork(); - if (pid < 0) return error.ForkFailed; - if (pid == 0) { - // The parent's end of the socketpair. Left open, the parent's recvmsg - // could never see EOF when the helper dies without sending anything, - // and a refused mount would hang instead of failing. - if (close_in_child >= 0) _ = libc.close(close_in_child); - _ = libc.execve(prog, argv, envp); - // 127 is the shell's convention for "not found". Reachable only when - // the binary vanished between the access(2) above and now. - libc._exit(127); - } - var status: c_int = 0; - while (true) { - const got = libc.waitpid(pid, &status, 0); - if (got == pid) break; - if (got < 0 and libc.errno(got) == .INTR) continue; - // Reaped by somebody else's SIGCHLD handler: the status is gone, and - // the descriptor either arrived or it did not. Claim success and let - // the recvmsg be the judge. - return 0; - } - // WIFEXITED/WEXITSTATUS spelled out: std has no portable macro, and a - // helper killed by a signal is not a helper that refused the mount. - if (status & 0x7f != 0) return error.FusermountKilled; - return @intCast((status >> 8) & 0xff); -} - -/// Receive the `/dev/fuse` descriptor. fusermount3 sends it as an SCM_RIGHTS -/// control message alongside exactly one byte of ordinary data, and the byte is -/// not padding: a control message with no data attached may be dropped, so both -/// sides are required to send at least one. -/// -/// `MSG_CMSG_CLOEXEC` is the important flag. Every pane shell is forked from -/// this process and inherits open descriptors; a bash holding a copy of this -/// one keeps the FUSE connection alive after pardes exits, and the mount stays -/// up, unkillable, answering nothing, until that shell dies. -fn receiveFd(sock: c_int) !c_int { - var byte: [1]u8 = undefined; - var iov = [1]std.posix.iovec{.{ .base = &byte, .len = 1 }}; - var control: [cmsgSpace(@sizeOf(c_int))]u8 align(@alignOf(libc.cmsghdr)) = undefined; - while (true) { - var msg: libc.msghdr = .{ - .name = null, - .namelen = 0, - .iov = &iov, - .iovlen = 1, - .control = &control, - .controllen = @intCast(control.len), - .flags = 0, - }; - const n = libc.recvmsg(sock, &msg, linux.MSG.CMSG_CLOEXEC); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.CommSocketFailed; - } - // EOF: the helper exited without sending anything, which is what a - // refused mount looks like from here. - if (n == 0) return error.FusermountRefused; - if (@as(usize, @intCast(msg.controllen)) < cmsgLen(@sizeOf(c_int))) return error.NoDescriptor; - const cmsg: *const libc.cmsghdr = @ptrCast(&control); - if (cmsg.level != libc.SOL.SOCKET or cmsg.type != libc.SCM.RIGHTS) return error.NoDescriptor; - if (@as(usize, @intCast(cmsg.len)) < cmsgLen(@sizeOf(c_int))) return error.NoDescriptor; - var fd: c_int = -1; - @memcpy( - std.mem.asBytes(&fd), - control[cmsgAlign(@sizeOf(libc.cmsghdr))..][0..@sizeOf(c_int)], - ); - if (fd < 0) return error.NoDescriptor; - return fd; - } -} - -/// `mkdir -p` for the mount point, 0700. The leaf is this process's own pid -/// directory and the parent is `.../pardes`, which on a fresh machine does not -/// exist; without the -p the whole feature would switch itself off in silence -/// on exactly the machines that never used it before. Same shape as -/// `nested.zig`'s ensureSocketDir, and 0700 for the same reason: what lives -/// under here takes commands. -fn ensureDir(path: [:0]const u8) void { - var partial: [4096:0]u8 = undefined; - if (path.len >= partial.len) return; - @memcpy(partial[0 .. path.len + 1], path[0 .. path.len + 1]); - for (1..path.len) |i| { - if (path[i] != '/') continue; - partial[i] = 0; - _ = libc.mkdir(partial[0..i :0], 0o700); - partial[i] = '/'; - } - _ = libc.mkdir(path, 0o700); -} - -/// Unmount and remove `/` for every pid that is gone. A pardes killed -/// with SIGKILL runs no defer, so its mount outlives it as an ENOTCONN stump -/// that `ls` reports as a permission error and that nothing else will ever -/// clean up — the snapshot suite alone would leave one per aborted run. -/// Bounded: one readdir of a directory only we write to, one kill(0) each. -/// Mirrors nested.zig's socket sweep deliberately, including the ESRCH rule: -/// 0 means alive, EPERM means alive and someone else's, only ESRCH is a corpse. -pub fn sweepStale(dir: []const u8) void { - if (comptime !supported) return; - var dir_buf: [4096:0]u8 = undefined; - const dir_z = std.fmt.bufPrintSentinel(&dir_buf, "{s}", .{dir}, 0) catch return; - const d = libc.opendir(dir_z) orelse return; - defer _ = libc.closedir(d); - const me = libc.getpid(); - while (libc.readdir(d)) |ent| { - const name = std.mem.sliceTo(&ent.name, 0); - // Strictly digits: parseInt would accept `+7` and `-7`, and this - // function unmounts and removes whatever it answers about. - if (name.len == 0) continue; - for (name) |ch| if (!std.ascii.isDigit(ch)) break; - if (std.mem.indexOfNone(u8, name, "0123456789") != null) continue; - const pid = std.fmt.parseInt(libc.pid_t, name, 10) catch continue; - if (pid == me) continue; - const rc = libc.kill(pid, @enumFromInt(0)); - if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var path_buf: [4096:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ dir, name }, 0) catch continue; - // Always ours to remove: the name is a pid under a directory only - // pardes writes to, and taking the stump away is the point of a sweep. - unmountPath(path, true); - } -} - -/// Run the helper's unmount and, when the directory is ours, take it away. -/// Best effort in both halves: an already-unmounted point makes fusermount3 -/// complain (which -q swallows) and a non-empty one makes rmdir fail, and -/// neither is worth a diagnostic at exit. -/// -/// `remove_dir` is not a convenience. The *unmount* is always right — the mount -/// is ours whoever made the directory — but the *rmdir* is only right for a -/// point pardes derived itself (`/`, which `ensureDir` created). -/// A `--fs=` the user named is theirs, and removing it is the same -/// overreach `sweepStale` is already refused under an explicit `--fs` for. -fn unmountPath(path: [:0]const u8, remove_dir: bool) void { - if (findFusermount()) |prog| { - var argv: [7:null]?[*:0]const u8 = undefined; - unmountArgv(&argv, prog.ptr, path.ptr); - // A minimal environment: the helper wants nothing of ours, and the one - // variable that WOULD change its behaviour is the comm descriptor it - // must not find here. - const envp = [_:null]?[*:0]const u8{null}; - _ = spawnHelper(prog.ptr, &argv, &envp, -1) catch {}; - } - if (remove_dir) _ = libc.rmdir(path); -} - -// --------------------------------------------------------------------------- -// the park table -// --------------------------------------------------------------------------- - -/// How many kernel requests may be outstanding at once. Every slot is either in -/// flight (handed to the core, not yet answered) or parked (the core said -/// `.again`). In-flight slots are transient — the host answers each request -/// inside the same drain step — so in practice this counts BLOCKED READERS: one -/// slot per process sitting on `event` or `log`. A session with 32 of those has -/// 32 scripts watching it. -/// -/// Overflow is a refusal, not a queue: `take` answers EAGAIN and the descriptor -/// keeps being read. See its comment for why the tempting alternative (stop -/// reading and let the kernel hold the surplus) is a deadlock. -const max_slots = 32; - -/// Bytes of request payload a slot can own. A parked request's `data` cannot go -/// on borrowing the read buffer (the next `next()` overwrites it), so it is -/// copied in at parse time when it fits. This covers every payload that can -/// realistically block: a LOOKUP name is at most 255 bytes and a ctl verb line -/// or an event write-back is a few dozen. A WRITE larger than this is left -/// borrowed and answered EAGAIN if the core ever tries to park it — a write is -/// a transaction in this design and is not supposed to block, and growing this -/// table by 64 KiB a slot to make an impossible case zero-copy is the wrong -/// trade. -const park_data_max = 512; - -const Slot = struct { - used: bool = false, - /// The core answered `.again`; `retry()` will offer it back. - parked: bool = false, - /// Already offered in this retry round. Reset when a round finds nothing, - /// which is what gives every parked request exactly one attempt per frame - /// instead of letting the oldest one starve the rest. - retried: bool = false, - /// `req.data` points into `data` below rather than into the read buffer. - copied: bool = false, - /// Arrival order, so retries are FIFO: the reader that blocked first is - /// offered first. - seq: u64 = 0, - op: Opcode = @enumFromInt(0), - req: acmefs.Req = undefined, - data: [park_data_max]u8 = undefined, -}; - -// --------------------------------------------------------------------------- -// Fs -// --------------------------------------------------------------------------- - -pub const Fs = struct { - pub const Options = struct { - /// Absolute path of the mount point. Absolute because it is handed to a - /// setuid program that resolves it against its own cwd, and because the - /// unmount at exit must name the same place after any chdir. - mount: []const u8, - /// The largest WRITE payload the kernel may send in one request, and - /// therefore the size of the read buffer. 64 KiB matches what a `cp` - /// into `body` will use; smaller only splits the same bytes into more - /// round trips. - max_write: u32 = 64 * 1024, - /// Whether pardes made this directory and may therefore remove it at - /// exit. True for the derived `/`, false for a - /// `--fs=` the user named. See `unmountPath`. - owns_dir: bool = false, - }; - - gpa: std.mem.Allocator, - /// The `/dev/fuse` descriptor. -1 once torn down; every entry point checks - /// it, so a double deinit and a post-unmount drain are both no-ops. - fd: c_int = -1, - /// Set when the connection is gone (ENODEV/ECONNABORTED, or DESTROY). - /// `next()` stops reading; replies are still written because a slot may be - /// mid-flight and the write simply fails. - dead: bool = false, - path: [:0]u8, - /// Mirrors `Options.owns_dir`; gates the rmdir in `deinit`. - owns_dir: bool = false, - /// One request per read(2), so this is sized for the largest request that - /// exists: header + fuse_write_in + max_write. Below FUSE_MIN_READ_BUFFER - /// the kernel refuses to hand over requests at all and answers the client - /// EIO. 8-aligned so the parse can point structs at it. - buf: []align(8) u8, - /// Encoded `fuse_dirent`s. Separate from `buf` because a readdir reply is - /// built while its request is still being read from `buf`. - dirents: [8192]u8 align(8) = undefined, - - uid: u32, - gid: u32, - max_write: u32, - /// The minor the kernel offered, echoed back at INIT. Kept for the record: - /// it is the one number in this file that a future feature would consult. - minor: u32 = 0, - - slots: [max_slots]Slot = @splat(.{}), - seq: u64 = 0, - thread: ?std.Thread = null, - /// main -> poller, an `eventfd(2)`. The main thread adds 1 per completed - /// drain and the poller's blocking read takes the whole counter in one go, - /// which is the "collapse the acknowledgements that piled up while we were - /// not waiting" behaviour a pipe needed three functions and a nonblocking - /// toggle to fake. Not a condition variable, because the poller is blocked - /// in `poll()` most of the time and an fd is the only thing that both - /// `poll()` and a blocking read can wait on — which is what lets shutdown - /// break it out of either state. - /// - /// The counter cannot say "stop": a stop and a drain acknowledgement that - /// race are summed into one indistinguishable number. `stopping` is the - /// sticky half of the signal, and is re-read after every wake; the eventfd - /// only ever means "look again". The store/write and read/load pair is a - /// release/acquire edge over the eventfd's own wait-queue lock, so a poller - /// that observes the increment observes the flag with it. - ctl: c_int = -1, - stopping: std.atomic.Value(bool) = .init(false), - wake_ctx: ?*anyopaque = null, - wake_fn: ?*const fn (?*anyopaque) void = null, - - /// Mount, hand out the descriptor, and complete the INIT handshake. On - /// return the filesystem is live: the kernel will start sending lookups the - /// moment anything touches the directory. - pub fn mount(gpa: std.mem.Allocator, opts: Options) !*Fs { - if (comptime !supported) return error.Unsupported; - if (opts.mount.len == 0 or opts.mount[0] != '/') return error.MountPathNotAbsolute; - - const path = try gpa.dupeZ(u8, opts.mount); - errdefer gpa.free(path); - ensureDir(path); - - const buf_len = @max( - min_read_buffer, - @sizeOf(fuse_in_header) + @sizeOf(fuse_write_in) + @as(usize, opts.max_write), - ); - const buf = try gpa.alignedAlloc(u8, .@"8", buf_len); - errdefer gpa.free(buf); - - const fd = try mountFusermount(gpa, path); - errdefer _ = libc.close(fd); - - const fs = try gpa.create(Fs); - errdefer gpa.destroy(fs); - fs.* = .{ - .gpa = gpa, - .fd = fd, - .path = path, - .buf = buf, - .uid = libc.getuid(), - .gid = libc.getgid(), - .max_write = opts.max_write, - .owns_dir = opts.owns_dir, - }; - // Still blocking here on purpose: INIT is already queued (fusermount3 - // completed mount(2) before it sent us the descriptor), and a - // non-blocking read would make the handshake a spin loop. - try fs.handshake(); - try fs.setNonblocking(); - return fs; - } - - /// socketpair, fork the setuid helper, take the descriptor it sends back. - fn mountFusermount(gpa: std.mem.Allocator, path: [:0]const u8) !c_int { - const prog = findFusermount() orelse return error.FusermountMissing; - var sv: [2]c_int = undefined; - if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM, 0, &sv) != 0) return error.SocketPairFailed; - // Both ends close-on-exec first, then the child's end is un-marked just - // before the fork. The window in between is what any *other* thread's - // fork would inherit, and pane shells are forked with forkpty and - // inherit everything open. - setCloexec(sv[0]); - setCloexec(sv[1]); - errdefer _ = libc.close(sv[0]); - - var opts_buf: [128:0]u8 = undefined; - var commfd_buf: [32:0]u8 = undefined; - const opts = mountOpts(&opts_buf); - const commfd = commfdEnv(&commfd_buf, sv[1]); - - const envp = try buildEnv(gpa, commfd); - defer gpa.free(envp); - var argv: [6:null]?[*:0]const u8 = undefined; - mountArgv(&argv, prog.ptr, opts.ptr, path.ptr); - - clearCloexec(sv[1]); - const code = spawnHelper(prog.ptr, &argv, @ptrCast(envp.ptr), sv[0]) catch |err| { - _ = libc.close(sv[1]); - return err; - }; - // Ours to close either way: the child has its own copy, and while we - // hold one the recvmsg below can never see EOF when the helper dies. - _ = libc.close(sv[1]); - if (code == 127) return error.FusermountMissing; - - const fd = try receiveFd(sv[0]); - if (code != 0) { - _ = libc.close(fd); - return error.FusermountFailed; - } - _ = libc.close(sv[0]); - return fd; - } - - /// Read the kernel's INIT and answer it. Negotiating nothing is the design: - /// every flag is a kernel behaviour we would then have to honour forever, - /// and this filesystem wants none of them — no readdirplus (whose ENOSYS - /// has no fallback and would fail every getdents), no atomic O_TRUNC (so - /// `> file` arrives as a plain SETATTR the core already handles), no POSIX - /// or BSD locks (flags = 0 makes the kernel set `no_lock`/`no_flock` and - /// answer them itself). - fn handshake(fs: *Fs) !void { - const n = readFull(fs.fd, fs.buf); - if (n < @sizeOf(fuse_in_header) + @sizeOf(fuse_init_in)) return error.InitFailed; - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - if (@as(Opcode, @enumFromInt(h.opcode)) != .init) return error.InitFailed; - const in: *const fuse_init_in = @ptrCast(@as([*]align(8) u8, @alignCast(fs.buf.ptr + @sizeOf(fuse_in_header)))); - // A major mismatch is fatal and there is nothing to negotiate: the - // kernel aborts the connection, and answering anyway just delays the - // failure to the first syscall through the mount. - if (in.major != kernel_version) return error.InitVersion; - fs.minor = in.minor; - - const out: fuse_init_out = .{ - .major = kernel_version, - // Capped, not echoed: see `kernel_minor`. - .minor = @min(in.minor, kernel_minor), - // Zero, not "some readahead": with FOPEN_DIRECT_IO there is no page - // cache to read ahead into, and a nonzero value here only invites - // the kernel to ask for bytes nobody wanted. - .max_readahead = 0, - .flags = 0, - // Left at zero so the kernel keeps its own defaults; a nonzero - // max_background is the one that silently caps concurrency. - .max_background = 0, - .congestion_threshold = 0, - .max_write = fs.max_write, - // 1 ns. Timestamps on this filesystem are all zero anyway, but a - // time_gran of 0 is not a legal granularity. - .time_gran = 1, - .max_pages = 0, - .map_alignment = 0, - .flags2 = 0, - .max_stack_depth = 0, - // 0 = no server timeout. A timeout would let the kernel abort the - // connection while a legitimately parked `event` read waits. - .request_timeout = 0, - .unused = @splat(0), - }; - fs.answer(h.unique, std.mem.asBytes(&out), &.{}); - return; - } - - fn setNonblocking(fs: *Fs) !void { - const flags = libc.fcntl(fs.fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return error.FcntlFailed; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - if (libc.fcntl(fs.fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))) < 0) - return error.FcntlFailed; - } - - /// Answer everything still held, abort the connection, unmount, remove the - /// directory. The order is not interchangeable: - /// - /// 1. reply -ENODEV to every slot, so a reader blocked on `event` gets an - /// error rather than being left in uninterruptible sleep. - /// 2. close the descriptor, which aborts the connection — the backstop - /// for anything that raced step 1, since the kernel then fails every - /// pending request itself. - /// 3. only then unmount, because a mount whose server is gone is exactly - /// what `fusermount3 -u -z` is for. - /// 4. remove the directory, but only when pardes made it: the derived - /// `/` is ours, a `--fs=` the user named is not. - pub fn deinit(fs: *Fs) void { - const gpa = fs.gpa; - fs.stopThread(); - if (fs.fd >= 0) { - for (&fs.slots) |*s| { - if (!s.used) continue; - fs.answerErr(s.req.tag, .NODEV); - s.* = .{}; - } - _ = libc.close(fs.fd); - fs.fd = -1; - } - if (comptime supported) unmountPath(fs.path, fs.owns_dir); - gpa.free(fs.path); - gpa.free(fs.buf); - gpa.destroy(fs); - } - - /// This mount as the three functions `fs_service` actually calls. The - /// adapter exists so that file needs no `@import("fuse.zig")` to drive a - /// filesystem: `retry`, `next` and `reply` were always its whole use of an - /// `Fs`, and naming them lets a second transport answer the same calls. - /// - /// The thunks are three lines each because a `*Fs` is not an `*anyopaque` - /// and a vtable cannot hold the typed function directly. That is the entire - /// cost of the seam. - pub fn transport(fs: *Fs) fs_service.Transport { - return .{ .ctx = fs, .vtable = &transport_vtable }; - } - - const transport_vtable: fs_service.Transport.VTable = .{ - .retry = transportRetry, - .next = transportNext, - .reply = transportReply, - }; - - fn transportRetry(ctx: *anyopaque) ?acmefs.Req { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - return fs.retry(); - } - - fn transportNext(ctx: *anyopaque) ?acmefs.Req { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - return fs.next(); - } - - fn transportReply(ctx: *anyopaque, r: *const acmefs.Reply, bytes: []const u8) void { - const fs: *Fs = @ptrCast(@alignCast(ctx)); - fs.reply(r, bytes); - } - - // -- request pump ------------------------------------------------------- - - /// Parse the next pending kernel request, or null when the descriptor is - /// drained. Call in a loop until null; the loop is the batch, and one wake - /// serves all of it. - /// - /// The returned `Req.data` borrows storage owned by this `Fs` and is valid - /// until the next `next()` call. The core copies whatever it keeps — the - /// same rule as `.pty_read`. - /// - /// Requests the core has no business seeing are answered here and the loop - /// continues, so a caller never observes them. - /// - /// Running this to null is also what acknowledges the batch to the poll - /// thread, so a host that stops early keeps the poller waiting and loses - /// wake latency until the next frame. It is not a correctness bug — the - /// remaining requests simply wait in the kernel — but the loop is the - /// contract. - pub fn next(fs: *Fs) ?acmefs.Req { - if (comptime !supported) return null; - // Only the EAGAIN arm below releases the poller, and deliberately so. - // Every other null return from here implies `dead`, which is write-once - // and means reads on the descriptor are failing: posting would send the - // poller back into `poll()` on a still-open fd that reports POLLIN - // forever, wake the host, drain to this same null, and spin two threads - // at 100%. Parking the poller in `consume()` is the right resting state - // for a connection that can never produce work again; `stopThread` - // releases it. `fd < 0` is unreachable here, since only `deinit` sets it - // and it joins the poller first. - if (fs.fd < 0 or fs.dead) return null; - while (true) { - const n = libc.read(fs.fd, fs.buf.ptr, fs.buf.len); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - .AGAIN => { - // Drained: release the poller (see `post`). - fs.post(); - return null; - }, - // The request was interrupted or aborted between being queued - // and being read; there is nothing to answer. - .NOENT => continue, - // ENODEV (connection aborted, or we were unmounted from under - // ourselves) and ECONNABORTED are terminal. Anything else here - // is not a thing /dev/fuse does, and treating the unknown as - // terminal beats a loop that reads -1 forever. - else => { - fs.dead = true; - return null; - }, - }; - if (n == 0) { - fs.dead = true; - return null; - } - const total: usize = @intCast(n); - // Cannot happen (the kernel writes whole requests) but the parse - // below indexes on it. - if (total < @sizeOf(fuse_in_header)) continue; - if (fs.dispatch(total)) |req| return req; - } - } - - /// Offer parked requests back, one per call. Call in a loop until null, - /// once per frame, before `next()`: the null both ends the round and resets - /// it, so every parked request gets exactly one attempt per frame and a - /// permanently blocked reader cannot starve the others. - pub fn retry(fs: *Fs) ?acmefs.Req { - if (comptime !supported) return null; - if (fs.fd < 0) return null; - var best: ?usize = null; - for (&fs.slots, 0..) |*s, i| { - if (!s.used or !s.parked or s.retried) continue; - if (best == null or s.seq < fs.slots[best.?].seq) best = i; - } - const i = best orelse { - for (&fs.slots) |*s| s.retried = false; - return null; - }; - fs.slots[i].retried = true; - // In flight again: `reply()` re-parks it if the core still has nothing. - fs.slots[i].parked = false; - return fs.slots[i].req; - } - - /// Write the core's answer, or park the request when it said `.again`. - /// Called from the `.fs_reply` effect; `bytes` is the payload resolved by - /// `pardes.fsPayload` and is borrowed only for the duration of this call. - pub fn reply(fs: *Fs, r: *const acmefs.Reply, bytes: []const u8) void { - if (comptime !supported) return; - const i = fs.findSlot(r.tag) orelse return; // interrupted, or torn down - const s = &fs.slots[i]; - - if (r.status == .again) { - // The one case a park is refused: a payload too large to have been - // copied at parse time still borrows the read buffer, so parking it - // would park a dangling slice. EAGAIN is honest — the writer can - // retry — and by construction unreachable, since the core answers - // writes as transactions and only reads ever block. - if (!s.copied and s.req.data.len != 0) { - fs.answerErr(s.req.tag, .AGAIN); - fs.release(i); - return; - } - s.parked = true; - return; - } - - if (r.status == .err) { - fs.answerErr(s.req.tag, @enumFromInt(if (r.errno == 0) @intFromEnum(libc.E.IO) else r.errno)); - fs.release(i); - return; - } - - switch (s.req.op) { - .lookup => { - const out: fuse_entry_out = .{ - .nodeid = r.attr.node, - // Node ids are never reused in this filesystem (pane - // serials are monotonic), which is exactly the condition - // for a constant generation to be safe. - .generation = 0, - // No caching, at all. Every file here changes under the - // reader's feet, and a cached negative lookup would make - // `new/` (which CREATES a pane) work exactly once. - .entry_valid = 0, - .attr_valid = 0, - .entry_valid_nsec = 0, - .attr_valid_nsec = 0, - .attr = fs.attr(r.attr, r.attr.node), - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .getattr, .setattr => { - const out: fuse_attr_out = .{ - .attr_valid = 0, - .attr_valid_nsec = 0, - .dummy = 0, - .attr = fs.attr(r.attr, s.req.node), - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .open => { - const out: fuse_open_out = .{ - .fh = r.handle, - // Direct IO for files; nothing for directories, where the - // flag has no meaning and FOPEN_CACHE_DIR (which we do not - // set) is the caching knob. An uncached directory is the - // point: `new/` and the pane list change constantly. - .open_flags = if (s.op == .opendir) 0 else FOPEN_DIRECT_IO, - .backing_id = 0, - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .read => { - // Never more than was asked for: a read reply longer than - // `size` is a protocol error the kernel answers with EIO. - const len = @min(bytes.len, s.req.size); - fs.answer(s.req.tag, &.{}, bytes[0..len]); - }, - .readdir => { - const room = @min(@as(usize, s.req.size), fs.dirents.len); - const len = encodeDirents(fs.dirents[0..room], bytes, s.req.off); - fs.answer(s.req.tag, &.{}, fs.dirents[0..len]); - }, - .write => { - // The core's own count, not the request size: `data` refusing a - // partial grapheme is a real short write, and claiming the - // whole request would tell the writer its trailing bytes - // landed when they did not. Clamped anyway, because a count - // larger than what was offered makes the kernel advance a file - // offset past bytes that never existed. - const out: fuse_write_out = .{ - .size = @min(r.written, s.req.size), - .padding = 0, - }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - .release => fs.answer(s.req.tag, &.{}, &.{}), - .statfs => { - // Synthetic numbers, but not arbitrary ones: `namelen` is what - // pathconf(_PC_NAME_MAX) returns and a zero there makes some - // tools refuse to create any name at all, and `bsize` is what - // `stat` reports as the IO block size. - const out: fuse_statfs_out = .{ .st = .{ - .blocks = 0, - .bfree = 0, - .bavail = 0, - .files = 0, - .ffree = 0, - .bsize = 4096, - .namelen = 255, - .frsize = 4096, - .padding = 0, - .spare = @splat(0), - } }; - fs.answer(s.req.tag, std.mem.asBytes(&out), &.{}); - }, - } - fs.release(i); - } - - /// Translate one request. Null means it was answered here. - fn dispatch(fs: *Fs, total: usize) ?acmefs.Req { - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - // Bounded by the header's own length, not just by what the read - // returned. They agree on /dev/fuse, and taking the smaller of the two - // is what keeps a WRITE from claiming payload it did not bring even if - // some future kernel ever pads a request. - const end = @min(total, @max(@as(usize, h.len), @sizeOf(fuse_in_header))); - const body: []align(8) const u8 = @alignCast(fs.buf[@sizeOf(fuse_in_header)..end]); - const op: Opcode = @enumFromInt(h.opcode); - switch (op) { - // Already answered in the handshake. A second INIT cannot happen; - // answering it again is cheaper than a special case that could. - .init => { - fs.answerErr(h.unique, .INVAL); - return null; - }, - // NEVER replied to. The kernel does not track these as pending - // requests, so a reply carries a `unique` it will not recognise — - // -ENOENT at best, and at worst a reply matched against a *live* - // request that happens to share the number. Ignoring the refcount - // itself is fine: this filesystem's node table is bounded by the - // pane count, so nothing grows. - .forget, .batch_forget => return null, - // Answer the ORIGINAL with EINTR and drop it. This is the only - // thing standing between a SIGKILLed reader of `event` and - // permanent uninterruptible sleep: after the fatal signal the - // kernel's last wait is not killable, so the process survives its - // own kill until this reply lands. No reply to the interrupt - // itself — its unique is `original | 1` and the kernel keeps no - // pending entry for it, while answering -ENOSYS would switch - // interrupts off for the whole connection and take the escape - // hatch away. - .interrupt => { - if (body.len >= @sizeOf(fuse_interrupt_in)) { - const in: *const fuse_interrupt_in = @ptrCast(body.ptr); - if (fs.findSlot(in.unique)) |i| { - fs.answerErr(fs.slots[i].req.tag, .INTR); - fs.release(i); - } - } - return null; - }, - // A missing reply here hangs `umount` outright. - .destroy => { - fs.answer(h.unique, &.{}, &.{}); - fs.dead = true; - return null; - }, - // -ENOSYS rather than an empty reply: the kernel sets `no_flush` - // and stops sending them, so this costs one round trip for the - // whole connection instead of one per close(2). Nothing here has - // buffered state for a flush to commit. - .flush => { - fs.answerErr(h.unique, .NOSYS); - return null; - }, - .lookup => { - // The name is the whole body, NUL terminated. An empty name is - // not a lookup of anything. - const name = std.mem.sliceTo(body, 0); - if (name.len == 0) { - fs.answerErr(h.unique, .INVAL); - return null; - } - return fs.take(op, .{ - .tag = h.unique, - .op = .lookup, - .node = h.nodeid, - .data = name, - }); - }, - .getattr => { - const in = fs.arg(fuse_getattr_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .getattr, - .node = h.nodeid, - // `fh` is only meaningful with the flag; reading it blind - // hands the core a handle from an unrelated open. - .handle = if (in.getattr_flags & FUSE_GETATTR_FH != 0) @truncate(in.fh) else 0, - }); - }, - .setattr => { - const in = fs.arg(fuse_setattr_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .setattr, - .node = h.nodeid, - .handle = @truncate(in.fh), - // The `> file` path, and the only setattr this filesystem - // has an opinion about. A truncate to a nonzero length is - // not expressible in the core's ABI and is reported as no - // truncate at all: the reply still carries the current - // attributes, so ftruncate(fd, n) succeeds and changes - // nothing, which is what every synthetic file here wants. - .truncate = in.valid & FATTR_SIZE != 0 and in.size == 0, - }); - }, - .open, .opendir => { - // The flags are read only to reject a short body: this - // filesystem's permission model is the mode bits each synthetic - // file reports from GETATTR, which the kernel enforces itself, - // so the access mode has nothing left to say here. - if (fs.arg(fuse_open_in, body) == null) return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .open, - .node = h.nodeid, - }); - }, - .read, .readdir => { - const in = fs.arg(fuse_read_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = if (op == .readdir) .readdir else .read, - .node = h.nodeid, - .handle = @truncate(in.fh), - .off = in.offset, - .size = in.size, - }); - }, - .write => { - const in = fs.arg(fuse_write_in, body) orelse return null; - const payload = body[@sizeOf(fuse_write_in)..]; - // Trust the header's length over the struct's: a `size` larger - // than what arrived would read past the request. - const len = @min(@as(usize, in.size), payload.len); - return fs.take(op, .{ - .tag = h.unique, - .op = .write, - .node = h.nodeid, - .handle = @truncate(in.fh), - .off = in.offset, - .size = @intCast(len), - .data = payload[0..len], - }); - }, - .release, .releasedir => { - const in = fs.arg(fuse_release_in, body) orelse return null; - return fs.take(op, .{ - .tag = h.unique, - .op = .release, - .node = h.nodeid, - .handle = @truncate(in.fh), - }); - }, - .statfs => return fs.take(op, .{ - .tag = h.unique, - .op = .statfs, - .node = h.nodeid, - }), - // Everything else. -ENOSYS is not a shrug: for most of these the - // kernel caches the answer and stops asking (`no_access`, - // `no_getxattr`, `no_statx`, `no_poll`, `no_lseek`, `no_create`), - // so one refusal switches the whole feature off for the connection. - // The mutations (mkdir, unlink, rename, link, symlink) are refused - // because this tree is generated: its shape follows the pane list - // and there is nothing for a user to create or remove in it. - // READDIRPLUS is not in this list by accident — it is unreachable, - // because INIT never sets FUSE_DO_READDIRPLUS, and it has to stay - // that way: its -ENOSYS has NO fallback in the kernel and would - // fail every getdents through the mount. - else => { - fs.answerErr(h.unique, .NOSYS); - return null; - }, - } - } - - /// Point a request struct at the read buffer. Null (and an EINVAL reply) - /// when the kernel sent less than the struct, which cannot happen but would - /// otherwise be a read past the buffer. - fn arg(fs: *Fs, comptime T: type, body: []align(8) const u8) ?*const T { - if (body.len < @sizeOf(T)) { - const h: *const fuse_in_header = @ptrCast(fs.buf.ptr); - fs.answerErr(h.unique, .INVAL); - return null; - } - return @ptrCast(body.ptr); - } - - /// Move a parsed request into a slot and hand it to the caller. Small - /// payloads are copied in here so that a later park has stable bytes; a - /// large one stays borrowed (see `park_data_max`). - /// - /// Null (and an EAGAIN reply) when the table is full. That is the whole - /// reason `next` reads unconditionally instead of gating on a free slot: - /// gating looks like polite backpressure and is a deadlock. With 32 readers - /// blocked on `event`, refusing to read the descriptor means the INTERRUPT - /// that would free a slot is never read either, so a SIGKILLed reader stays - /// in uninterruptible sleep forever and every unrelated `ls` of the mount - /// hangs behind it. Reading and answering EAGAIN keeps FORGET, INTERRUPT, - /// DESTROY and the ENOSYS family flowing — none of which need a slot — and - /// turns "too many blocked readers" into one failed syscall the caller can - /// see and retry. - fn take(fs: *Fs, op: Opcode, req: acmefs.Req) ?acmefs.Req { - const i = fs.freeSlot() orelse { - fs.answerErr(req.tag, .AGAIN); - return null; - }; - const s = &fs.slots[i]; - s.* = .{ - .used = true, - .seq = fs.seq, - .op = op, - .req = req, - }; - fs.seq += 1; - if (req.data.len != 0 and req.data.len <= park_data_max) { - @memcpy(s.data[0..req.data.len], req.data); - s.copied = true; - s.req.data = s.data[0..req.data.len]; - } - return s.req; - } - - fn freeSlot(fs: *Fs) ?usize { - for (&fs.slots, 0..) |*s, i| if (!s.used) return i; - return null; - } - - fn findSlot(fs: *Fs, tag: u64) ?usize { - for (&fs.slots, 0..) |*s, i| if (s.used and s.req.tag == tag) return i; - return null; - } - - fn release(fs: *Fs, i: usize) void { - fs.slots[i] = .{}; - } - - /// `Reply.Attr` -> `fuse_attr`. `node` is the fallback inode for replies - /// that do not name one (a getattr answers about a node the request already - /// identified); a zero `st_ino` is a value no filesystem is allowed to - /// report and some tools treat it as a deleted entry. - fn attr(fs: *const Fs, a: acmefs.Reply.Attr, node: u64) fuse_attr { - const ino = if (a.node != 0) a.node else node; - return .{ - .ino = ino, - .size = a.size, - // 512-byte units, as `stat` wants them. Rounded up so a nonempty - // file never reports zero blocks, which `du` reads as a hole. - .blocks = (a.size + 511) / 512, - .atime = 0, - .mtime = 0, - .ctime = 0, - .atimensec = 0, - .mtimensec = 0, - .ctimensec = 0, - .mode = (if (a.dir) S_IFDIR else S_IFREG) | @as(u32, a.mode), - // 2 for a directory (itself and `.`) is what every tool expects; - // `find` in particular uses it to decide whether to recurse. - .nlink = if (a.dir) 2 else 1, - // The mounting user owns everything: without `allow_other` nobody - // else can reach the mount at all, and reporting some other owner - // would only make `ls -l` lie. - .uid = fs.uid, - .gid = fs.gid, - .rdev = 0, - .blksize = 4096, - .flags = 0, - }; - } - - // -- reply framing ------------------------------------------------------ - - /// One `writev` per reply: header, then the op's fixed out struct, then the - /// payload. Split into iovecs rather than assembled in a buffer so that a - /// megabyte read out of a pane's text is written straight from the core's - /// bytes — the whole point of `Reply.Payload.region`. - fn answer(fs: *Fs, unique: u64, fixed: []const u8, payload: []const u8) void { - var header: fuse_out_header = .{ - .len = @intCast(@sizeOf(fuse_out_header) + fixed.len + payload.len), - .@"error" = 0, - .unique = unique, - }; - var iov: [3]std.posix.iovec_const = undefined; - var n: usize = 1; - iov[0] = .{ .base = std.mem.asBytes(&header).ptr, .len = @sizeOf(fuse_out_header) }; - if (fixed.len != 0) { - iov[n] = .{ .base = fixed.ptr, .len = fixed.len }; - n += 1; - } - if (payload.len != 0) { - iov[n] = .{ .base = payload.ptr, .len = payload.len }; - n += 1; - } - fs.writeReply(iov[0..n], header.len); - } - - /// An error reply is header-only: the kernel checks `nbytes == - /// sizeof(oh)` when `error != 0` and answers -EINVAL otherwise, which - /// leaves the original request pending forever. - fn answerErr(fs: *Fs, unique: u64, e: libc.E) void { - var header: fuse_out_header = .{ - .len = @sizeOf(fuse_out_header), - .@"error" = -@as(i32, @intFromEnum(e)), - .unique = unique, - }; - const iov = [1]std.posix.iovec_const{ - .{ .base = std.mem.asBytes(&header).ptr, .len = @sizeOf(fuse_out_header) }, - }; - fs.writeReply(&iov, header.len); - } - - fn writeReply(fs: *Fs, iov: []const std.posix.iovec_const, expect: u32) void { - if (fs.fd < 0) return; - while (true) { - const n = libc.writev(fs.fd, iov.ptr, @intCast(iov.len)); - if (n < 0) switch (libc.errno(n)) { - .INTR => continue, - // /dev/fuse writes never block, so this is not the usual - // EAGAIN; retrying is the only thing that can make progress and - // it cannot loop forever because the kernel is not waiting on - // us. - .AGAIN => continue, - // The request is no longer pending: it was interrupted or the - // connection was aborted between the read and this write. - // Dropping it is correct — there is nothing left to answer. - .NOENT => return, - else => { - fs.dead = true; - return; - }, - }; - // A short write to /dev/fuse is not a thing (the kernel takes the - // whole reply or none of it), so this can only mean the reply was - // malformed and the request is still pending. Nothing useful is - // left to do about it here, and pretending otherwise would hide it. - std.debug.assert(@as(u32, @intCast(n)) == expect); - return; - } - } - - // -- poll thread -------------------------------------------------------- - - /// Start the one background thread: it waits for POLLIN and calls `wake`. - /// It never touches the descriptor's data, never sees a request and never - /// calls the core; the host's `wake` is expected to do nothing but post an - /// event on the loop, exactly like the inotify thread's. - /// - /// Optional by design. A host with no threads simply does not call this and - /// drains from its frame poll instead; it loses wake latency and nothing - /// else, which is what makes the no-parallelism backend work unchanged. - pub fn wakeThread(fs: *Fs, ctx: ?*anyopaque, wake: *const fn (?*anyopaque) void) !void { - if (comptime !supported) return; - if (fs.thread != null) return; - // Blocking on purpose: `consume` is a blocking read on this descriptor. - // The write side cannot block anyway — an eventfd write only waits for - // a counter one short of `maxInt(u64)` to be drained, which is not - // reachable at one increment per drain. - const efd = libc.eventfd(0, linux.EFD.CLOEXEC); - if (efd < 0) return error.EventFdFailed; - fs.ctl = efd; - fs.wake_ctx = ctx; - fs.wake_fn = wake; - fs.thread = std.Thread.spawn(.{}, pollLoop, .{fs}) catch |err| { - _ = libc.close(efd); - fs.ctl = -1; - return err; - }; - } - - fn stopThread(fs: *Fs) void { - if (comptime !supported) return; - // `ctl` and `thread` are set and cleared together, so there is no - // descriptor to close on the path where no poller was ever started. - const t = fs.thread orelse return; - // The flag before the wake, never after: a poller that reads the - // increment must not then find `stopping` false and go back to sleep on - // a counter nobody will raise again. With this order every state the - // poller can be in ends in an exit — the loop condition, the `poll()` - // (the eventfd becomes readable) and the blocking wait for a drain - // acknowledgement (the read returns) all re-read the flag. - fs.stopping.store(true, .release); - fs.post(); - t.join(); - fs.thread = null; - _ = libc.close(fs.ctl); - fs.ctl = -1; - } - - /// Raise the counter by one: "the descriptor has been drained, you may poll - /// again", or during teardown "look at `stopping`". Without the drain half - /// of that handshake the poller re-polls a level-triggered descriptor that - /// is still readable and spins a core until the main thread catches up; - /// with it, one wake serves one batch. - fn post(fs: *Fs) void { - if (fs.ctl < 0) return; - const one: u64 = 1; - _ = libc.write(fs.ctl, std.mem.asBytes(&one), @sizeOf(u64)); - } - - fn pollLoop(fs: *Fs) void { - if (comptime !supported) return; - while (!fs.stopping.load(.acquire)) { - var fds = [2]libc.pollfd{ - .{ .fd = fs.fd, .events = libc.POLL.IN, .revents = 0 }, - .{ .fd = fs.ctl, .events = libc.POLL.IN, .revents = 0 }, - }; - const rc = libc.poll(&fds, 2, -1); - if (rc < 0) { - if (libc.errno(rc) == .INTR) continue; - return; - } - // Shutdown, or an acknowledgement for a drain that happened without - // us. Take the whole counter and re-poll either way: a leftover - // count would make the wait below return instantly and turn the - // next wake into a spin. - if (fds[1].revents != 0 and fs.consume()) return; - if (fds[0].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL) != 0) return; - if (fds[0].revents & libc.POLL.IN == 0) continue; - - (fs.wake_fn.?)(fs.wake_ctx); - // Wait for the main thread to finish the batch. This is the whole - // anti-spin mechanism; see `post`. - if (fs.consume()) return; - } - } - - /// Block until the counter is nonzero, then take all of it. True when the - /// poller must exit, which is `stopping` and nothing else: the count itself - /// carries no meaning beyond "look again". - /// - /// The read blocks, including on the branch that reached here from a - /// `poll()` that only *said* the descriptor was readable. That is safe - /// because `stopThread` closes `ctl` after `join()` and never before: an - /// eventfd raises neither POLLERR nor POLLHUP, so the one revents value - /// that would be readable-but-not-readable is POLLNVAL, and a closed - /// descriptor is the only thing that produces it. - fn consume(fs: *Fs) bool { - var v: u64 = undefined; - while (true) { - const n = libc.read(fs.ctl, std.mem.asBytes(&v), @sizeOf(u64)); - // A short read and an EOF do not exist on an eventfd: the read - // returns 8 or -1. So anything but EINTR means this descriptor is - // not the one we opened, and exiting beats spinning on it. - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return true; - } - return fs.stopping.load(.acquire); - } - } -}; - -// --------------------------------------------------------------------------- -// descriptor flags -// --------------------------------------------------------------------------- - -fn setCloexec(fd: c_int) void { - const FD_CLOEXEC: c_int = 1; - _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); -} - -/// The child of the mount fork must KEEP this descriptor across execve — it is -/// the whole channel the setuid helper answers on. -fn clearCloexec(fd: c_int) void { - _ = libc.fcntl(fd, libc.F.SETFD, @as(c_int, 0)); -} - -fn setNonblock(fd: c_int) void { - const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; - var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); - o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); -} - -/// One blocking read, EINTR-safe. Used only for the INIT handshake, where the -/// descriptor is still blocking; every later read goes through `next()`. -fn readFull(fd: c_int, buf: []u8) usize { - while (true) { - const n = libc.read(fd, buf.ptr, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return 0; - } - return @intCast(n); - } -} - -// --------------------------------------------------------------------------- -// tests -// --------------------------------------------------------------------------- -// -// No test here mounts anything: a real mount needs the setuid helper, a -// writable runtime directory and a kernel that will let go of it again, which -// is a snapshot test's job and not a unit test's. What is testable without a -// mount is everything that has ever actually been wrong in a FUSE server — -// struct sizes, dirent alignment, cookies, the INIT reply, the park table, and -// the argv handed to a setuid program. Those are what follows, driven through a -// socketpair standing in for /dev/fuse. - -const testing = std.testing; - -/// Build an `Fs` with no mount, wired to `fd`. The socketpair replaces -/// /dev/fuse for the codec tests: the kernel's side of the conversation is -/// written by hand and the reply is read back and compared byte for byte. -fn testFs(gpa: std.mem.Allocator, fd: c_int) !*Fs { - const fs = try gpa.create(Fs); - fs.* = .{ - .gpa = gpa, - .fd = fd, - .path = try gpa.dupeZ(u8, "/nonexistent"), - .buf = try gpa.alignedAlloc(u8, .@"8", min_read_buffer), - .uid = 1000, - .gid = 1000, - .max_write = 4096, - }; - return fs; -} - -fn testFsFree(fs: *Fs) void { - const gpa = fs.gpa; - gpa.free(fs.path); - gpa.free(fs.buf); - gpa.destroy(fs); -} - -/// Frame a request the way the kernel does and push it at the server. -fn pushRequest(fd: c_int, unique: u64, op: Opcode, nodeid: u64, body: []const u8) !void { - var buf: [4096]u8 align(8) = undefined; - const h: fuse_in_header = .{ - .len = @intCast(@sizeOf(fuse_in_header) + body.len), - .opcode = @intFromEnum(op), - .unique = unique, - .nodeid = nodeid, - .uid = 1000, - .gid = 1000, - .pid = 1, - .total_extlen = 0, - .padding = 0, - }; - @memcpy(buf[0..@sizeOf(fuse_in_header)], std.mem.asBytes(&h)); - @memcpy(buf[@sizeOf(fuse_in_header)..][0..body.len], body); - const total = @sizeOf(fuse_in_header) + body.len; - try testing.expectEqual(@as(isize, @intCast(total)), libc.write(fd, &buf, total)); -} - -/// Read one reply back off the socketpair. -fn readReply(fd: c_int, buf: []u8) ![]u8 { - const n = libc.read(fd, buf.ptr, buf.len); - try testing.expect(n >= @sizeOf(fuse_out_header)); - return buf[0..@intCast(n)]; -} - -fn outHeader(bytes: []const u8) fuse_out_header { - var h: fuse_out_header = undefined; - @memcpy(std.mem.asBytes(&h), bytes[0..@sizeOf(fuse_out_header)]); - return h; -} - -/// A socketpair standing in for /dev/fuse. SEQPACKET, not STREAM, and that is -/// the whole point: the kernel's character device hands over exactly one -/// request per read(2) and takes exactly one reply per write(2), and a stream -/// socket would coalesce three requests into one read and let a codec that -/// ignores `fuse_in_header.len` pass anyway. -/// -/// Both ends non-blocking. The server's end so `next()` meets EAGAIN where it -/// would on the real descriptor; the kernel's end so a test can assert that -/// NOTHING was written — which is what "a held request has no reply" and "a -/// FORGET is never answered" mean, and a blocking read would simply hang there -/// instead of failing. -fn testPair() ![2]c_int { - var sv: [2]c_int = undefined; - if (libc.socketpair(libc.AF.UNIX, libc.SOCK.SEQPACKET, 0, &sv) != 0) return error.SocketPairFailed; - setNonblock(sv[0]); - setNonblock(sv[1]); - return sv; -} - -test "lookup round trip: parse borrows the name, reply frames an entry" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 100, .lookup, 1, "index\x00"); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.lookup, req.op); - try testing.expectEqual(@as(u64, 100), req.tag); - try testing.expectEqual(@as(u64, 1), req.node); - try testing.expectEqualStrings("index", req.data); - // Drained, and nothing else was invented. - try testing.expect(fs.next() == null); - - fs.reply(&.{ - .tag = 100, - .attr = .{ .node = 7, .size = 42, .mode = 0o444 }, - }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - const h = outHeader(got); - try testing.expectEqual(@as(u32, @sizeOf(fuse_out_header) + @sizeOf(fuse_entry_out)), h.len); - try testing.expectEqual(@as(u32, @intCast(got.len)), h.len); - try testing.expectEqual(@as(i32, 0), h.@"error"); - try testing.expectEqual(@as(u64, 100), h.unique); - - var entry: fuse_entry_out = undefined; - @memcpy(std.mem.asBytes(&entry), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_entry_out)]); - try testing.expectEqual(@as(u64, 7), entry.nodeid); - // Caching off in both directions, or `new/` creates a pane once and - // then serves the cached negative lookup forever. - try testing.expectEqual(@as(u64, 0), entry.entry_valid); - try testing.expectEqual(@as(u64, 0), entry.attr_valid); - try testing.expectEqual(@as(u64, 7), entry.attr.ino); - try testing.expectEqual(@as(u64, 42), entry.attr.size); - try testing.expectEqual(S_IFREG | @as(u32, 0o444), entry.attr.mode); - try testing.expectEqual(@as(u32, 1), entry.attr.nlink); - try testing.expectEqual(@as(u32, 1000), entry.attr.uid); - // The slot went back. - try testing.expect(fs.freeSlot() != null); - try testing.expectEqual(@as(?usize, null), fs.findSlot(100)); -} - -test "read reply is capped at the requested size and written as one frame" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 3, - .offset = 8, - .size = 4, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 200, .read, 5, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.read, req.op); - try testing.expectEqual(@as(u32, 3), req.handle); - try testing.expectEqual(@as(u64, 8), req.off); - try testing.expectEqual(@as(u32, 4), req.size); - - // The core offers more than was asked for; a reply longer than `size` is - // answered EIO by the kernel, so it has to be clamped here. - fs.reply(&.{ .tag = 200, .payload = .{ .staged = 9 } }, "abcdefghi"); - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + 4), got.len); - try testing.expectEqual(@as(u32, @intCast(got.len)), outHeader(got).len); - try testing.expectEqualStrings("abcd", got[@sizeOf(fuse_out_header)..]); -} - -test "an error reply is header only" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 300, .lookup, 1, "nope\x00"); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 300, .status = .err, .errno = @intFromEnum(libc.E.NOENT) }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - // len MUST be exactly the header when error is set; anything else makes the - // kernel answer -EINVAL and leaves the request pending forever. - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u32, @sizeOf(fuse_out_header)), h.len); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOENT)), h.@"error"); -} - -test "opcodes the core never sees are answered here" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - var buf: [512]u8 = undefined; - - // FORGET and BATCH_FORGET get NO reply, ever: the kernel keeps no pending - // entry for them, so a reply would carry a unique it does not recognise. - const forget: fuse_forget_in = .{ .nlookup = 1 }; - try pushRequest(sv[1], 400, .forget, 7, std.mem.asBytes(&forget)); - const batch: fuse_batch_forget_in = .{ .count = 0, .dummy = 0 }; - try pushRequest(sv[1], 402, .batch_forget, 0, std.mem.asBytes(&batch)); - // ...and a mutation is refused, which is the first thing that produces a - // reply, proving nothing was written for the two above. - try pushRequest(sv[1], 404, .mkdir, 1, "x\x00"); - try testing.expect(fs.next() == null); - - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u64, 404), h.unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), h.@"error"); - - // DESTROY must be answered or umount hangs. - try pushRequest(sv[1], 406, .destroy, 0, &.{}); - try testing.expect(fs.next() == null); - const destroyed = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), destroyed.len); - try testing.expectEqual(@as(i32, 0), outHeader(destroyed).@"error"); - try testing.expectEqual(@as(u64, 406), outHeader(destroyed).unique); - - // FLUSH is refused so the kernel stops sending one per close(2). - fs.dead = false; - const flush: fuse_flush_in = .{ .fh = 1, .unused = 0, .padding = 0, .lock_owner = 0 }; - try pushRequest(sv[1], 408, .flush, 1, std.mem.asBytes(&flush)); - try testing.expect(fs.next() == null); - const flushed = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), outHeader(flushed).@"error"); -} - -test "setattr size=0 is the truncate the kernel sends instead of O_TRUNC" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var in: fuse_setattr_in = std.mem.zeroes(fuse_setattr_in); - in.valid = FATTR_SIZE; - in.size = 0; - try pushRequest(sv[1], 500, .setattr, 9, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.setattr, req.op); - try testing.expect(req.truncate); - - // A nonzero size is not a truncate this ABI can express, and must not be - // reported as one: the core would clear a pane on `ftruncate(fd, 10)`. - in.size = 10; - try pushRequest(sv[1], 502, .setattr, 9, std.mem.asBytes(&in)); - fs.reply(&.{ .tag = 500, .attr = .{ .node = 9 } }, &.{}); - const req2 = fs.next() orelse return error.NoRequest; - try testing.expect(!req2.truncate); - - fs.reply(&.{ .tag = 502, .attr = .{ .node = 9, .size = 3, .dir = true } }, &.{}); - var buf: [512]u8 = undefined; - _ = try readReply(sv[1], &buf); // the first reply - const got = try readReply(sv[1], &buf); - var out: fuse_attr_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_attr_out)]); - try testing.expectEqual(S_IFDIR | @as(u32, 0o600), out.attr.mode); - try testing.expectEqual(@as(u32, 2), out.attr.nlink); - try testing.expectEqual(@as(u64, 0), out.attr_valid); -} - -test "open reports direct io for files and nothing for directories" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - var buf: [512]u8 = undefined; - - // O_WRONLY - const wr: fuse_open_in = .{ .flags = 1, .open_flags = 0 }; - try pushRequest(sv[1], 600, .open, 4, std.mem.asBytes(&wr)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.open, req.op); - fs.reply(&.{ .tag = 600, .handle = 11 }, &.{}); - var got = try readReply(sv[1], &buf); - var open_out: fuse_open_out = undefined; - @memcpy(std.mem.asBytes(&open_out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_open_out)]); - try testing.expectEqual(@as(u64, 11), open_out.fh); - try testing.expectEqual(FOPEN_DIRECT_IO, open_out.open_flags); - - // O_RDONLY on a directory - const rd: fuse_open_in = .{ .flags = 0, .open_flags = 0 }; - try pushRequest(sv[1], 602, .opendir, 1, std.mem.asBytes(&rd)); - const dir_req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.open, dir_req.op); - fs.reply(&.{ .tag = 602, .handle = 12 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&open_out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_open_out)]); - // No FOPEN_CACHE_DIR either: the pane list changes between two `ls`. - try testing.expectEqual(@as(u32, 0), open_out.open_flags); -} - -test "write borrows the payload and reports the core's own count" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var body: [@sizeOf(fuse_write_in) + 5]u8 = undefined; - const in: fuse_write_in = .{ - .fh = 2, - .offset = 0, - .size = 5, - .write_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(body[@sizeOf(fuse_write_in)..], "hello"); - try pushRequest(sv[1], 700, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.write, req.op); - try testing.expectEqualStrings("hello", req.data); - - fs.reply(&.{ .tag = 700, .written = 5 }, &.{}); - var buf: [512]u8 = undefined; - var got = try readReply(sv[1], &buf); - var out: fuse_write_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 5), out.size); - - // A short count is a real answer — `data` refusing a partial grapheme — - // and must reach write(2) as a short write rather than as a full one. - try pushRequest(sv[1], 704, .write, 6, &body); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 704, .written = 3 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 3), out.size); - - // A count larger than what was offered would advance the file offset past - // bytes that never existed. - try pushRequest(sv[1], 706, .write, 6, &body); - _ = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = 706, .written = 99 }, &.{}); - got = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_write_out)]); - try testing.expectEqual(@as(u32, 5), out.size); -} - -test "a write whose size lies about the payload is clamped to what arrived" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var body: [@sizeOf(fuse_write_in) + 2]u8 = undefined; - var in: fuse_write_in = std.mem.zeroes(fuse_write_in); - in.size = 4096; // more than the two bytes that follow - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(body[@sizeOf(fuse_write_in)..], "hi"); - try pushRequest(sv[1], 702, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqualStrings("hi", req.data); - try testing.expectEqual(@as(u32, 2), req.size); -} - -test "dirent encoding: 8-byte records, cookies from the request offset" { - var staged: [64]u8 = undefined; - var w: usize = 0; - // node=2 kind=file name="addr" - std.mem.writeInt(u64, staged[w..][0..8], 2, .little); - staged[w + 8] = 0; - staged[w + 9] = 4; - @memcpy(staged[w + 10 ..][0..4], "addr"); - w += 14; - // node=3 kind=dir name="new" - std.mem.writeInt(u64, staged[w..][0..8], 3, .little); - staged[w + 8] = 1; - staged[w + 9] = 3; - @memcpy(staged[w + 10 ..][0..3], "new"); - w += 13; - - var out: [128]u8 = undefined; - const n = encodeDirents(&out, staged[0..w], 5); - // 24 + 4 -> 32; 24 + 3 -> 32. A record that is not a multiple of 8 - // desynchronises the kernel's parse of everything after it. - try testing.expectEqual(@as(usize, 64), n); - try testing.expectEqual(@as(u64, 0), n % rec_align); - - try testing.expectEqual(@as(u64, 2), std.mem.readInt(u64, out[0..8], .little)); - // Cookies continue from the request's offset: the kernel sends the last - // `off` it saw as the next request's offset, so restarting at 1 would loop - // the directory forever. - try testing.expectEqual(@as(u64, 6), std.mem.readInt(u64, out[8..16], .little)); - try testing.expectEqual(@as(u32, 4), std.mem.readInt(u32, out[16..20], .little)); - try testing.expectEqual(DT_REG, std.mem.readInt(u32, out[20..24], .little)); - try testing.expectEqualStrings("addr", out[24..28]); - // Padding zeroed, so the wire is deterministic. - try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, out[28..32]); - - try testing.expectEqual(@as(u64, 3), std.mem.readInt(u64, out[32..40], .little)); - try testing.expectEqual(@as(u64, 7), std.mem.readInt(u64, out[40..48], .little)); - try testing.expectEqual(DT_DIR, std.mem.readInt(u32, out[52..56], .little)); - try testing.expectEqualStrings("new", out[56..59]); -} - -test "dirent encoding stops cleanly when the reply buffer or the staging runs out" { - var staged: [64]u8 = undefined; - std.mem.writeInt(u64, staged[0..8], 9, .little); - staged[8] = 0; - staged[9] = 4; - @memcpy(staged[10..14], "body"); - std.mem.writeInt(u64, staged[14..22], 10, .little); - staged[22] = 0; - staged[23] = 4; - @memcpy(staged[24..28], "ctl!"); - - // Room for one record only: the second comes back at the higher cookie. - var out: [40]u8 = undefined; - try testing.expectEqual(@as(usize, 32), encodeDirents(&out, staged[0..28], 0)); - - // A truncated staging record is dropped rather than guessed at. - try testing.expectEqual(@as(usize, 32), encodeDirents(&out, staged[0..26], 0)); - // Zero staged bytes is EOF, not an error. - try testing.expectEqual(@as(usize, 0), encodeDirents(&out, &.{}, 4)); - // A zero name length would make the kernel parse the padding as an entry. - var bad: [10]u8 = @splat(0); - try testing.expectEqual(@as(usize, 0), encodeDirents(&out, &bad, 0)); -} - -test "readdir reply carries encoded dirents built from the staged names" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 4096, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 800, .readdir, 1, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.readdir, req.op); - - var staged: [16]u8 = undefined; - std.mem.writeInt(u64, staged[0..8], 4, .little); - staged[8] = 1; - staged[9] = 5; - @memcpy(staged[10..15], "panes"); - fs.reply(&.{ .tag = 800, .payload = .{ .staged = 15 } }, staged[0..15]); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + 32), got.len); - const rec = got[@sizeOf(fuse_out_header)..]; - try testing.expectEqual(@as(u64, 4), std.mem.readInt(u64, rec[0..8], .little)); - try testing.expectEqual(@as(u64, 1), std.mem.readInt(u64, rec[8..16], .little)); - try testing.expectEqual(DT_DIR, std.mem.readInt(u32, rec[20..24], .little)); - try testing.expectEqualStrings("panes", rec[24..29]); -} - -test "INIT reply negotiates nothing and caps the minor at ours" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - fs.max_write = 64 * 1024; - - const in: fuse_init_in = .{ - .major = 7, - .minor = 45, - .max_readahead = 131072, - // Everything the kernel is willing to do. The point of the test is that - // none of it comes back. - .flags = 0xffff_ffff, - .flags2 = 0xffff_ffff, - .unused = @splat(0), - }; - try pushRequest(sv[1], 1, .init, 0, std.mem.asBytes(&in)); - try fs.handshake(); - try testing.expectEqual(@as(u32, 45), fs.minor); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header) + @sizeOf(fuse_init_out)), got.len); - try testing.expectEqual(@as(u64, 1), outHeader(got).unique); - var out: fuse_init_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_init_out)]); - try testing.expectEqual(@as(u32, 7), out.major); - try testing.expectEqual(@as(u32, 45), out.minor); - // The one assertion this test exists for. Every bit here is a kernel - // behaviour we would owe forever: readdirplus whose ENOSYS has no fallback, - // atomic O_TRUNC that would bypass the SETATTR the core handles, locks. - try testing.expectEqual(@as(u32, 0), out.flags); - try testing.expectEqual(@as(u32, 0), out.flags2); - try testing.expectEqual(@as(u32, 0), out.max_readahead); - try testing.expectEqual(@as(u32, 64 * 1024), out.max_write); - // A time granularity of zero is not a legal value. - try testing.expectEqual(@as(u32, 1), out.time_gran); - try testing.expectEqual(@as(u16, 0), out.request_timeout); - - // A newer kernel's minor is CAPPED, not echoed. `fc->minor` is our own - // declared level and it is what sizes the replies the kernel reads back - // from us, so claiming 7.99 on these structs promises fields they do not - // have. This assertion is the one the old `@min(in.minor, in.minor)` could - // not make. - const newer: fuse_init_in = .{ - .major = 7, - .minor = kernel_minor + 54, - .max_readahead = 0, - .flags = 0, - .flags2 = 0, - .unused = @splat(0), - }; - try pushRequest(sv[1], 2, .init, 0, std.mem.asBytes(&newer)); - try fs.handshake(); - const capped = try readReply(sv[1], &buf); - @memcpy(std.mem.asBytes(&out), capped[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_init_out)]); - try testing.expectEqual(kernel_minor, out.minor); - - // A foreign major is fatal, and answering it anyway only moves the failure - // to the first syscall through the mount. - const bad: fuse_init_in = .{ - .major = 8, - .minor = 0, - .max_readahead = 0, - .flags = 0, - .flags2 = 0, - .unused = @splat(0), - }; - try pushRequest(sv[1], 3, .init, 0, std.mem.asBytes(&bad)); - try testing.expectError(error.InitVersion, fs.handshake()); -} - -test "park table: again holds the request, retry offers it back once per round" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 64, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - // Two blocked readers of `event`, in arrival order. - try pushRequest(sv[1], 900, .read, 20, std.mem.asBytes(&in)); - try pushRequest(sv[1], 902, .read, 21, std.mem.asBytes(&in)); - const a = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = a.tag, .status = .again }, &.{}); - const b = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = b.tag, .status = .again }, &.{}); - try testing.expect(fs.next() == null); - // Nothing was written: a held request has no reply, which is the only way - // FUSE expresses blocking. - var buf: [512]u8 = undefined; - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); - - // One round offers each parked request exactly once, oldest first, and then - // ends. Without the per-round flag the oldest would be offered forever and - // the second reader would never be looked at again. - const r1 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 900), r1.tag); - fs.reply(&.{ .tag = r1.tag, .status = .again }, &.{}); - const r2 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 902), r2.tag); - fs.reply(&.{ .tag = r2.tag, .status = .again }, &.{}); - try testing.expect(fs.retry() == null); - - // ...and the next round starts over. - const r3 = fs.retry() orelse return error.NoRetry; - try testing.expectEqual(@as(u64, 900), r3.tag); - fs.reply(&.{ .tag = r3.tag, .payload = .{ .staged = 3 } }, "ev\n"); - const got = try readReply(sv[1], &buf); - try testing.expectEqualStrings("ev\n", got[@sizeOf(fuse_out_header)..]); - // The answered one is gone; the other is still held. - try testing.expectEqual(@as(?usize, null), fs.findSlot(900)); - try testing.expect(fs.findSlot(902) != null); -} - -test "park table: interrupt answers the original with EINTR and drops it" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 64, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - try pushRequest(sv[1], 1000, .read, 20, std.mem.asBytes(&in)); - try pushRequest(sv[1], 1002, .read, 21, std.mem.asBytes(&in)); - const a = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = a.tag, .status = .again }, &.{}); - const b = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = b.tag, .status = .again }, &.{}); - - // The kernel's interrupt names the ORIGINAL unique in its body; its own - // unique is `original | 1`, which is why it must not be echoed. - const intr: fuse_interrupt_in = .{ .unique = 1002 }; - try pushRequest(sv[1], 1002 | 1, .interrupt, 0, std.mem.asBytes(&intr)); - try testing.expect(fs.next() == null); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - // Exactly one reply, to the interrupted request, not to the interrupt. - // Getting this wrong leaves a SIGKILLed reader in uninterruptible sleep. - try testing.expectEqual(@as(usize, @sizeOf(fuse_out_header)), got.len); - const h = outHeader(got); - try testing.expectEqual(@as(u64, 1002), h.unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.INTR)), h.@"error"); - try testing.expectEqual(@as(?usize, null), fs.findSlot(1002)); - try testing.expect(fs.findSlot(1000) != null); - - // An interrupt for something we do not hold is ignored, not answered. - const stale: fuse_interrupt_in = .{ .unique = 4242 }; - try pushRequest(sv[1], 4243, .interrupt, 0, std.mem.asBytes(&stale)); - try testing.expect(fs.next() == null); - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); -} - -test "park table: a full table answers EAGAIN and keeps the descriptor flowing" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const in: fuse_read_in = .{ - .fh = 1, - .offset = 0, - .size = 8, - .read_flags = 0, - .lock_owner = 0, - .flags = 0, - .padding = 0, - }; - for (0..max_slots) |i| { - try pushRequest(sv[1], 2000 + i * 2, .read, 30, std.mem.asBytes(&in)); - const req = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = req.tag, .status = .again }, &.{}); - } - var buf: [512]u8 = undefined; - - // One more than the table holds. It is READ and refused, not left queued. - // Gating the read on a free slot is a deadlock dressed as backpressure: - // the INTERRUPT that frees a slot would never be read either, so a - // SIGKILLed reader would stay in uninterruptible sleep and every unrelated - // `ls` of the mount would hang behind the 32 blocked ones. Measured: that - // wedges a real mount. - try pushRequest(sv[1], 9998, .read, 30, std.mem.asBytes(&in)); - try testing.expect(fs.next() == null); - const refused = try readReply(sv[1], &buf); - try testing.expectEqual(@as(u64, 9998), outHeader(refused).unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.AGAIN)), outHeader(refused).@"error"); - - // And the requests that need no slot keep being answered with the table - // still full — DESTROY above all, since a missing reply to it hangs umount. - try pushRequest(sv[1], 9990, .access, 1, &.{}); - try testing.expect(fs.next() == null); - const nosys = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.NOSYS)), outHeader(nosys).@"error"); - - // An interrupt still lands, which is what lets a full table recover at all. - const intr: fuse_interrupt_in = .{ .unique = 2000 }; - try pushRequest(sv[1], 2001, .interrupt, 0, std.mem.asBytes(&intr)); - try testing.expect(fs.next() == null); - const killed = try readReply(sv[1], &buf); - try testing.expectEqual(@as(u64, 2000), outHeader(killed).unique); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.INTR)), outHeader(killed).@"error"); - - // ...and the freed slot takes the next request. - try pushRequest(sv[1], 9996, .read, 30, std.mem.asBytes(&in)); - const late = fs.next() orelse return error.NoRequest; - try testing.expectEqual(@as(u64, 9996), late.tag); -} - -test "park table: a payload too large to copy is refused rather than dangled" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const payload_len = park_data_max + 1; - var body: [@sizeOf(fuse_write_in) + payload_len]u8 = undefined; - var in: fuse_write_in = std.mem.zeroes(fuse_write_in); - in.size = payload_len; - @memcpy(body[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memset(body[@sizeOf(fuse_write_in)..], 'z'); - try pushRequest(sv[1], 3000, .write, 6, &body); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(@as(usize, payload_len), req.data.len); - - // Parking this would park a slice of the read buffer, which the next - // `next()` overwrites. EAGAIN is the honest answer. - fs.reply(&.{ .tag = 3000, .status = .again }, &.{}); - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - try testing.expectEqual(-@as(i32, @intFromEnum(libc.E.AGAIN)), outHeader(got).@"error"); - try testing.expectEqual(@as(?usize, null), fs.findSlot(3000)); - - // A payload that fits IS copied, so parking it is safe even after the read - // buffer has been reused. - var small: [@sizeOf(fuse_write_in) + 4]u8 = undefined; - in.size = 4; - @memcpy(small[0..@sizeOf(fuse_write_in)], std.mem.asBytes(&in)); - @memcpy(small[@sizeOf(fuse_write_in)..], "keep"); - try pushRequest(sv[1], 3002, .write, 6, &small); - const kept = fs.next() orelse return error.NoRequest; - fs.reply(&.{ .tag = kept.tag, .status = .again }, &.{}); - // Something else lands in the read buffer... - try pushRequest(sv[1], 3004, .statfs, 1, &.{}); - _ = fs.next() orelse return error.NoRequest; - // ...and the parked bytes survived it. - const again = fs.retry() orelse return error.NoRetry; - try testing.expectEqualStrings("keep", again.data); -} - -test "a reply for a tag we no longer hold is dropped, not written" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - // The interrupt path already answered and freed this one; a second reply - // would carry a unique the kernel does not recognise, and could in - // principle be matched against a live request that reused the number. - fs.reply(&.{ .tag = 12345 }, &.{}); - var buf: [512]u8 = undefined; - try testing.expect(libc.read(sv[1], &buf, buf.len) < 0); -} - -test "statfs reports a usable namelen" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - try pushRequest(sv[1], 1100, .statfs, 1, &.{}); - const req = fs.next() orelse return error.NoRequest; - try testing.expectEqual(acmefs.Op.statfs, req.op); - fs.reply(&.{ .tag = 1100 }, &.{}); - - var buf: [512]u8 = undefined; - const got = try readReply(sv[1], &buf); - var out: fuse_statfs_out = undefined; - @memcpy(std.mem.asBytes(&out), got[@sizeOf(fuse_out_header)..][0..@sizeOf(fuse_statfs_out)]); - // Zero here makes pathconf(_PC_NAME_MAX) return 0 and some tools then - // refuse to create any name at all. - try testing.expectEqual(@as(u32, 255), out.st.namelen); - try testing.expectEqual(@as(u32, 4096), out.st.bsize); -} - -test "the fusermount command line and environment" { - var opts_buf: [128:0]u8 = undefined; - const opts = mountOpts(&opts_buf); - try testing.expectEqualStrings("fsname=pardes,subtype=pardes,nosuid,nodev", opts); - // allow_other needs user_allow_other in /etc/fuse.conf, which is commented - // out on a stock install, and asking for it FAILS the whole mount rather - // than being ignored. default_permissions would move access control out of - // the core and into a mode nibble. - try testing.expect(std.mem.indexOf(u8, opts, "allow_other") == null); - try testing.expect(std.mem.indexOf(u8, opts, "default_permissions") == null); - - var env_buf: [32:0]u8 = undefined; - try testing.expectEqualStrings("_FUSE_COMMFD=7", commfdEnv(&env_buf, 7)); - - var argv: [6:null]?[*:0]const u8 = undefined; - mountArgv(&argv, "/usr/bin/fusermount3", opts.ptr, "/run/user/1000/pardes/42"); - try testing.expectEqualStrings("/usr/bin/fusermount3", std.mem.span(argv[0].?)); - try testing.expectEqualStrings("-o", std.mem.span(argv[1].?)); - try testing.expectEqualStrings("fsname=pardes,subtype=pardes,nosuid,nodev", std.mem.span(argv[2].?)); - // Without the `--` a mountpoint beginning with a dash is parsed as a flag - // by a setuid program. - try testing.expectEqualStrings("--", std.mem.span(argv[3].?)); - try testing.expectEqualStrings("/run/user/1000/pardes/42", std.mem.span(argv[4].?)); - try testing.expectEqual(@as(?[*:0]const u8, null), argv[5]); - - var uargv: [7:null]?[*:0]const u8 = undefined; - unmountArgv(&uargv, "/usr/bin/fusermount3", "/run/user/1000/pardes/42"); - try testing.expectEqualStrings("-u", std.mem.span(uargv[1].?)); - try testing.expectEqualStrings("-q", std.mem.span(uargv[2].?)); - // Lazy, or a pane shell with a cwd inside the mount makes the unmount fail - // with EBUSY and the mount outlives the editor. - try testing.expectEqualStrings("-z", std.mem.span(uargv[3].?)); - try testing.expectEqualStrings("--", std.mem.span(uargv[4].?)); - try testing.expectEqual(@as(?[*:0]const u8, null), uargv[6]); -} - -test "the child environment drops an inherited comm descriptor" { - if (comptime !supported) return; - const gpa = testing.allocator; - var buf: [32:0]u8 = undefined; - const commfd = commfdEnv(&buf, 5); - const env = try buildEnv(gpa, commfd); - defer gpa.free(env); - - // Exactly one _FUSE_COMMFD, and it is ours: getenv returns the FIRST match, - // so an inherited stale entry would win and fusermount3 would send the - // descriptor to a closed socket. - var seen: usize = 0; - var i: usize = 0; - while (env[i]) |entry| : (i += 1) { - if (std.mem.startsWith(u8, std.mem.span(entry), commfd_env ++ "=")) { - seen += 1; - try testing.expectEqualStrings("_FUSE_COMMFD=5", std.mem.span(entry)); - } - } - try testing.expectEqual(@as(usize, 1), seen); - try testing.expectEqual(@as(?[*:0]const u8, null), env[env.len - 1]); -} - -test "poll thread: one wake per drained batch, and stop joins from either state" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - var wakes: std.atomic.Value(u32) = .init(0); - const Sink = struct { - fn wake(ctx: ?*anyopaque) void { - const c: *std.atomic.Value(u32) = @ptrCast(@alignCast(ctx.?)); - _ = c.fetchAdd(1, .release); - } - }; - try fs.wakeThread(&wakes, Sink.wake); - - // One pending request, one wake. A FORGET is answered inside `next()` and - // never surfaces, so draining to null is the whole batch — and it is that - // null which raises the eventfd and lets the poller poll again. - const forget: fuse_forget_in = .{ .nlookup = 1 }; - try pushRequest(sv[1], 7000, .forget, 2, std.mem.asBytes(&forget)); - while (wakes.load(.acquire) == 0) std.Thread.yield() catch {}; - try testing.expectEqual(@as(?acmefs.Req, null), fs.next()); - - // The poller is now in one of the two states a stop has to break: still in - // the blocking wait, or back in `poll()` because the drain above beat the - // stop there. Which one is a race, deliberately unresolved — the assertion - // is that either joins, and a hang here is this test's only failure mode. - fs.stopThread(); - try testing.expect(fs.thread == null); - try testing.expectEqual(@as(c_int, -1), fs.ctl); -} - -test "poll thread: stop breaks a poller that never saw a request" { - if (comptime !supported) return; - const gpa = testing.allocator; - const sv = try testPair(); - defer { - _ = libc.close(sv[0]); - _ = libc.close(sv[1]); - } - const fs = try testFs(gpa, sv[0]); - defer testFsFree(fs); - - const Sink = struct { - fn wake(_: ?*anyopaque) void { - unreachable; // nothing is ever pending on this descriptor - } - }; - try fs.wakeThread(null, Sink.wake); - // Covers the two states with no acknowledgement in them at all: blocked in - // `poll()` with an idle descriptor, and not yet past the loop condition. - fs.stopThread(); - try testing.expect(fs.thread == null); -} - -test "mount refuses a relative point" { - if (comptime !supported) return; - try testing.expectError( - error.MountPathNotAbsolute, - Fs.mount(testing.allocator, .{ .mount = "relative/dir" }), - ); -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 3876a222..a7d9d167 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1,30 +1,4 @@ -//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input -//! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native), and -//! rasterizes the core's Surface: one instanced quad per cell, glyphs from a -//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, -//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the -//! real renderer, drives input from stdin, and captures frames to PPM. -//! -//! ...AND THE SAME WINDOW WITH NO CORE IN IT. `--attach`, and the `Attach` -//! builtin, hand this window's screen to a detached session (src/detached/): -//! the `Pardes` lives in THAT process, and this one sends the input it collects -//! and paints the frames it is sent. The two modes share every line that -//! touches SDL — `dispatch`/`keyDown` translate an SDL_Event once, -//! `renderFrame` rasterizes a `Surface` once, `putClipboard`/`takeClipboard` -//! and `look.openLink` are the desktop once — and differ only in where a -//! translated event goes and where the cells came from. `Input` is that seam, -//! and a null `core` inside it is what "attached" MEANS here: every function -//! that reads pane rects or theme colours off the core takes an optional one -//! and falls back to the body grid, because the wire carries cells, not the -//! layout that produced them. -//! -//! An attached window does NO machine-local work whatsoever: it forks no shell, -//! writes no file and watches no path, because the session process owns all of -//! that now (src/host_io.zig, src/file_watch.zig, src/detached/server.zig). -//! The only effects still on that wire are the three that need a human's own -//! display — `set_clipboard`, `read_clipboard`, `open_link` — and those land on -//! THIS display. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -32,28 +6,17 @@ const libc = std.c; const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells const pardes = @import("../pardes.zig"); -const host_api = @import("../host.zig"); // LspRequest, the one host type not re-exported const config = @import("../config.zig"); const look = @import("../look.zig"); -const message = @import("../message.zig"); +const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); -const user_config = @import("../user_config.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); -const shell_bin = @import("../shell_bin.zig"); -const nested = @import("../nested.zig"); -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); - -// The other half of `--detach`, and the reason this file has an `--attach` -// branch at all: the frontend side of a detached session is a window and a -// socket, and this file is already the one that owns a window. Just the one -// import: client.zig owns the frontend's whole side of this transport — the -// name resolution, the handshake, the poll interval and the decoded messages — -// so nothing here reaches past it to server.zig or wire.zig. +const ninep_io = @import("../9p_io.zig"); + const detached_client = @import("../detached/client.zig"); pub const c = @cImport({ @@ -62,13 +25,8 @@ pub const c = @cImport({ @cInclude("font.h"); }); -// The machine-local half of a host — fork a pane's shell, put bytes on a disk -// — is shared with the tty shell and the detached daemon. This file used to -// carry its own `forkShell`, `writeWholeFile` and `writeFd`, ten of eleven -// lines identical to that file's and one line short of its zero-write guard. const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); const log = std.log.scoped(.gui); @@ -83,32 +41,9 @@ const image_frag_spv = @embedFile("image.frag.spv"); const crt_vert_spv = @embedFile("crt.vert.spv"); const crt_frag_spv = @embedFile("crt.frag.spv"); -/// What the shell paints where the core painted nothing: behind the grid — the -/// strip left over when the window is not a whole number of cells tall — and -/// inside every cell the core left at its default background. The FALLBACK -/// for it, that is: the ground itself is the active theme's own background, -/// read off the core once a frame (see `ground`), and this is what a theme -/// with none of its own gets in an OPAQUE window. -/// -/// A theme declares no background (the curated `dark`, every vendored -/// `*_transparent`) to mean "wear whatever the terminal is wearing", and a -/// window has nothing to wear unless it is see-through. The AppKit shell -/// answers that by going transparent over an NSVisualEffectView -/// (pardes_theme_bg, docs/macos.md); this shell answers it with -/// `config.gui_transparent`, which asks SDL for a transparent window and lets -/// the compositor be the backdrop. That knob is off by default and costs a -/// readback per painted frame when it is on — the whole reason is written -/// where it is declared. Without it a themeless window keeps the -/// terminal-native dark it always wore. const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; -/// The ground under this frame, and whether it is a colour at all. -/// -/// `clear` is the themeless case in a transparent window: nothing is painted -/// there, the desktop is. `rgb` still carries the fallback colour because a -/// reverse-video cell puts the ground in its FOREGROUND, where it is a real -/// colour that paints — the same rule PardesView.styleFor states on macOS. const Ground = struct { rgb: [3]u8, clear: bool, @@ -118,22 +53,11 @@ const Ground = struct { } }; -/// The ground under this frame. The theme's OWN background and not the -/// animated chrome colour: taglines fade between themes over a handful of -/// frames, document backgrounds switch the instant the theme does, and this is -/// one of those. Asked per frame, so a `Theme` command takes hold without a -/// relaunch — and asked at all because a hand-agreed constant was a black line -/// along the two edges of every light-themed window. fn ground(theme_bg: ?[3]u8, transparent: bool) Ground { if (theme_bg) |rgb| return .opaqueRgb(rgb); return .{ .rgb = bg_default, .clear = transparent }; } -// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ -// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set -// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. -// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; -// mask=0 transparent — so data = set and mask = set|clr. const p9_arrow_clr = [32]u8{ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, @@ -152,7 +76,6 @@ const p9_arrow_mask = blk: { break :blk m; }; -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40). const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; @@ -161,9 +84,6 @@ const GlyphKey = struct { role: pardes.FontRole, }; -/// Raster parameters for the smaller tagline face. Taglines are a real second -/// grid: their glyph cell shrinks in both axes while pane geometry remains on -/// the body grid. const TaglineRaster = struct { scale: f32, width: u32, @@ -184,9 +104,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u return .{ .scale = scale, .width = @intCast(std.math.clamp(own_w, 1, fixed_w)), - // Tagline slots use only their first `height` texel rows. Centering is - // done by the cell quad, not baked into this baseline, so changing the - // live percentage changes the chrome band as well as the glyph ink. .baseline = std.math.clamp(own_ascent, 1, band_h), .height = @intCast(band_h), }; @@ -194,7 +111,6 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u const max_fallback_fonts = 1 + fonts.fallback_names.len; const LoadedFallback = struct { face: *c.UIFont, - /// Empty for embedded Adwaita; otherwise the owned bytes FreeType borrows. bytes: []u8 = &.{}, }; @@ -205,12 +121,6 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -/// SDL input can wake the loop faster than display cadence, so ticking once -/// per pass would make animation duration depend on pty traffic or mouse -/// motion — and `pump` deliberately spends no animation time of its own. This -/// monotonic gate keeps it near 60 Hz without sleeping the event loop. It is -/// consulted only AFTER a successful presentation: the current sample reaches -/// the screen before the display interval may advance it. const AnimationClock = struct { next_ns: u64 = 0, @@ -220,24 +130,19 @@ const AnimationClock = struct { return false; } if (clock.next_ns == 0) { - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.animation.frame_ns; + clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; return true; } }; -/// Commit exactly what the GPU accepted, then spend at most one display-clock -/// step. The order is the invariant: a persistent scene effect may keep -/// `AnimationClock` armed indefinitely, but it still cannot consume frame zero -/// of a panel/theme/hover animation created earlier in this loop before that -/// source sample is rendered. fn finishPresentedAnimationFrame( clock: *AnimationClock, core: *pardes.Pardes, - tracks: []const pardes.panel_animation.Track, + tracks: []const pardes.layout.Track, now_ns: u64, ) void { core.acknowledgePanelPresentation(tracks); @@ -248,9 +153,9 @@ test "GUI animation clock is active-only and cadence gated" { var clock: AnimationClock = .{}; try std.testing.expect(!clock.due(false, 100)); try std.testing.expect(!clock.due(true, 100)); - try std.testing.expect(!clock.due(true, 100 + pardes.animation.frame_ns - 1)); - try std.testing.expect(clock.due(true, 100 + pardes.animation.frame_ns)); - try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.frame_ns)); + try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1)); + try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns)); + try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns)); try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } @@ -261,13 +166,10 @@ test "a persistent scene presents a new panel's frame zero before advancing it" defer arena.deinit(); var clock: AnimationClock = .{}; - // The persistent effect arms the cadence gate before any panel animation - // exists—the state which used to make a due tick skip a new track's first - // sample in the old tick-before-render loop. core.settings.scene_effects.crt = true; const scene_frame = try core.render(arena.allocator()); finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100); - try std.testing.expectEqual(@as(u64, 100 + pardes.animation.frame_ns), clock.next_ns); + try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -277,13 +179,11 @@ test "a persistent scene presents a new panel's frame zero before advancing it" try std.testing.expect(track_count > 0); for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); - // The cadence is already due, but finishPresentedAnimationFrame consumes - // it only after acknowledging the frame-zero records above. finishPresentedAnimationFrame( &clock, core, first.panelTracks(), - 100 + pardes.animation.frame_ns, + 100 + pardes.layout.Animation.frame_ns, ); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); @@ -293,22 +193,6 @@ test "a persistent scene presents a new panel's frame zero before advancing it" const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); -// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. -// ONE size for the whole window, not one per pane: the core lays every pane -// out on a single uniform cell grid and Surface is one flat cols×rows array, -// so a second cell size would be a different core, not a different font. -// -// The step is 2 and not 1 because 1 is a press that sometimes does nothing — -// cell_w is round(advance × scale), the shipped face advances ~0.51px per px -// of size, and half the 1px steps therefore round to the same column width. A -// key that visibly works only every other press reads as a broken key. At 2 -// both axes move at every size in the range, on both parities of the ladder; -// that is what the test below walks. -// -// The ends are where a terminal stops being one. 8px is a 4×8 cell — the -// smallest thing with a glyph still in it — and 72px is 37×76, about thirty -// columns across a laptop screen. Past either the grid is not small or large, -// it is wedged, and there is no reset binding to get back out of it. const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; const font_px_max: f32 = 72.0; @@ -316,8 +200,6 @@ const font_px_max: f32 = 72.0; test "every font size step moves the cell, and the ends are reachable exactly" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - // Walk the whole range rather than only the 27px runtime ladder, so both - // odd and even rungs are exercised and a rounding stall cannot hide. var px = font_px_min + font_px_step; while (px <= font_px_max) : (px += 1.0) { var cw: c_int = 0; @@ -329,8 +211,6 @@ test "every font size step moves the cell, and the ends are reachable exactly" { c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } - // ...and the clamp dispatch runs parks on each end instead of walking off - // it, from any size a press can leave g.px on try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max)); } @@ -436,16 +316,11 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?; try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x); try std.testing.expect(t.max_y - t.min_y < b.max_y - b.min_y); - // Compare doubled centers to avoid floating point. Hinting may move either - // glyph by a pixel, but the smaller one must not hug an edge of the slot. const body_center_x: isize = @intCast(b.min_x + b.max_x); const tag_center_x: isize = @intCast(t.min_x + t.max_x); const body_center_y: isize = @intCast(b.min_y + b.max_y); const centered_top: isize = @intCast((@as(u32, @intCast(cell_h)) - tag.height) / 2); const tag_center_y: isize = @as(isize, @intCast(t.min_y + t.max_y)) + centered_top * 2; - // Compare positions in their own grids. Both faces are centred within - // their natural monospace advance; the smaller face is not padded back - // out to a body-width cell. const body_slot_center: isize = @intCast(@as(u32, @intCast(cell_w)) - 1); const tag_slot_center: isize = @intCast(tag.width - 1); try std.testing.expect(@abs((tag_center_x - tag_slot_center) - (body_center_x - body_slot_center)) <= 2); @@ -470,7 +345,6 @@ test "tagline percentage changes measured band height without body metrics" { try std.testing.expect(small.height < configured.height); try std.testing.expect(configured.height <= full.height); try std.testing.expect(full.height <= @as(u32, @intCast(cell_h))); - // The body measurement is an input and remains the same one-row grid. try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0)); } @@ -493,17 +367,13 @@ test "installed Nerd Symbols fallback covers Yazi directory icons" { if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font; } else return; - const bytes = try look.readFile(std.testing.allocator, candidate.path); + const bytes = try filesystem.readFile(std.testing.allocator, candidate.path); defer std.testing.allocator.free(bytes); const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(face); - // Yazi's default directory icon ``. try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff)); } -// One instance per body cell; a tagline cell adds its compact-grid foreground -// instance. The vertex shader expands each to a 2-triangle quad with -// gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. const CellInstance = extern struct { x0: f32, y0: f32, @@ -560,18 +430,9 @@ const ImageInstance = extern struct { }; const initial_image_capacity: u32 = pardes.MAX_PANES; -/// `CellInstance.effect` is an effect id in its low bits and flags in its top -/// two. Both shaders that read the field mask the id off with `0x3fffffff`; -/// widening this pair means widening that mask with it. const old_layer_bit: u32 = 0x8000_0000; -/// This cell's background IS the see-through ground: emit the glyph and let -/// the compositor keep the rest. Only ever set when `Ground.clear` holds, so -/// an opaque window never reaches the branch. const clear_bg_bit: u32 = 0x4000_0000; -/// Image placement retained across pane destruction. Deliberately does not -/// contain ImagePlace.rgba: the producer owns those bytes, while the renderer -/// retains only the already-uploaded GPU texture identified by `key`. const SavedImagePlace = struct { key: pardes.ImageCacheKey, pane: u8, @@ -607,8 +468,8 @@ const SavedImagePlace = struct { const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, - track: ?pardes.panel_animation.Track = null, - clip: ?pardes.panel_animation.Box = null, + track: ?pardes.layout.Track = null, + clip: ?pardes.layout.Box = null, old_layer: bool = false, }; @@ -642,7 +503,7 @@ test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect")); try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32)); - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, @@ -656,19 +517,19 @@ test "shader instance ABI carries aligned transition vectors" { } test "GUI paint plan snaps history effects without a frozen grid" { - const tracks = [_]pardes.panel_animation.Track{ + const tracks = [_]pardes.layout.Track{ .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); const ready = makePaintPlan(&tracks, true); try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { - const closing: pardes.panel_animation.Track = .{ + const closing: pardes.layout.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, @@ -678,7 +539,7 @@ test "closing tombstone overlays but never owns canonical cells" { const plan = makePaintPlan(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.panel_animation.Phase.closing, plan.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { @@ -699,8 +560,6 @@ fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cel return true; } -// ---- touch: per-finger tracking + shared scroll/tap machines ---- - const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; const TouchPoint = struct { @@ -716,8 +575,6 @@ const TouchPoint = struct { const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; -/// A finger event with SDL's normalized 0..1 coordinates — the one shape both -/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; const Touch = struct { @@ -770,8 +627,6 @@ const Touch = struct { return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; } - /// keep scroll state in sync with the set of active fingers: exactly two - /// active fingers begin (or re-key) a pair; anything else resets it. fn syncPair(t: *Touch) void { var ids: [2]u64 = .{ 0, 0 }; var count: usize = 0; @@ -793,8 +648,6 @@ const Touch = struct { t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; } - /// finger lift: a pair that never scrolled is a two-finger TAP — returns - /// its center (computed with the lifting finger's final position). fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const tap = f.kind == .up and !t.scroll.scrolled; @@ -837,7 +690,6 @@ fn takeScrollTicks(accum: *f32) i32 { return ticks; } -/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute. fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w); @@ -889,61 +741,54 @@ fn normCell(norm: f32, win: f32, cell: f32) u16 { return @intFromFloat(@max(0, @floor(px / cell))); } -// ---- pty plumbing: reader threads feed a mutex-protected queue ---- +const Pty = struct { + fd: c_int, + pid: libc.pid_t, + serial: u32, + kill_at: i64 = 0, + reader: ?std.Thread = null, + stop: [2]c_int = .{ -1, -1 }, +}; -const Pty = struct { fd: c_int, pid: libc.pid_t }; +const RetiredShell = struct { pid: libc.pid_t = 0, kill_at: i64 = 0 }; const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, - eof: struct { pane: u8, gen: u32, fd: c_int }, - /// a language query finished on its own thread (see lspThread) - lsp: struct { id: u32, rows: []u8 }, - /// a language SERVER changed state; narrated by the client's reader - /// threads through the status sink, lsp-allocator-owned + eof: struct { pane: u8, gen: u32, failure: ?anyerror = null }, + lsp: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// a selection-filter worker finished; every stdout is gpa-owned pipe: selection_pipe.Response, - /// something happened in a watched directory (see watchThread) files_changed, - /// a pardes launched inside this one sent us a builtin command line (see - /// lookThread); gpa-owned, like `output` bytes - command: []u8, - /// `--fs`: the /dev/fuse descriptor has requests on it. Carries nothing — - /// the drain lives in pollFrame, and this only ends a blocking - /// SDL_WaitEventTimeout. Posted by the poll thread and, when a batch hits - /// its cap, by pollFrame itself. Lossy under backpressure on purpose: a - /// full queue already holds something that will wake the loop. fs_ready, fn deinit(m: Msg, gpa: std.mem.Allocator, lsp_allocator: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), - .lsp => |l| lsp_allocator.free(l.rows), + .lsp => |l| if (l.rows) |rows| lsp_allocator.free(rows), .lsp_status => |t| lsp_allocator.free(t), .pipe => |response_value| { var response = response_value; response.deinit(gpa); }, - .command => |line| gpa.free(line), .eof, .files_changed, .fs_ready => {}, } } }; -/// The shared snapshot/worker pair. This file carried its own `LspJob` with -/// "tty.zig's LspJob, and copied for the same reason" over the top; both copies -/// are now one module, and the AppKit shell — which had neither — uses it too. -const lsp_host = @import("../lsp_host.zig"); - const LspWorkers = struct { + const capacity = 16; active: std.atomic.Value(usize) = .init(0), - fn start(workers: *LspWorkers) void { - _ = workers.active.fetchAdd(1, .monotonic); + fn start(workers: *LspWorkers) bool { + var count = workers.active.load(.monotonic); + while (count < capacity) + count = workers.active.cmpxchgWeak(count, count + 1, .monotonic, .monotonic) orelse return true; + return false; } fn finish(workers: *LspWorkers) void { - _ = workers.active.fetchSub(1, .release); + const previous = workers.active.fetchSub(1, .release); + std.debug.assert(previous > 0); } fn wait(workers: *LspWorkers) void { @@ -955,17 +800,13 @@ const LspWorkers = struct { } }; -const max_pipe_tasks = 16; - -/// Moved to `selection_pipe.Tasks`, beside the Job it tracks — tty.zig carried -/// this same table verbatim. -const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const queue_capacity = 512; +const output_capacity = queue_capacity - pardes.MAX_PANES; const MessageBatch = struct { - items: [queue_capacity]Msg = undefined, + items: [queue_capacity + PipeTasks.capacity + 2]Msg = undefined, len: usize = 0, fn slice(batch: *MessageBatch) []Msg { @@ -978,16 +819,77 @@ const Queue = struct { lsp_allocator: std.mem.Allocator, lsp_workers: *LspWorkers, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push - // 0.16 has no std.Thread.Mutex; critical sections here are a few - // instructions, so spinning on the lock-free std.atomic.Mutex is enough. - mutex: std.atomic.Mutex = .unlocked, + mutex: libc.pthread_mutex_t = .{}, + space: libc.pthread_cond_t = .{}, items: [queue_capacity]Msg = undefined, head: usize = 0, len: usize = 0, closed: bool = false, + files_changed: bool = false, + readers: [pardes.MAX_PANES]?u32 = @splat(null), + waiting: usize = 0, + lsp_id: ?u32 = null, + completions: [PipeTasks.capacity + 1]Msg = undefined, + completion_len: usize = 0, fn lock(q: *Queue) void { - while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); + std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); + } + + fn unlock(q: *Queue) void { + std.debug.assert(libc.pthread_mutex_unlock(&q.mutex) == .SUCCESS); + } + + fn wake(q: *Queue) void { + if (q.sdl_wake) { + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + fn acceptReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + std.debug.assert(q.readers[pane] == null); + q.readers[pane] = gen; + } + + fn cancelReader(q: *Queue, pane: u8, gen: u32) void { + q.lock(); + defer q.unlock(); + if (q.readers[pane] != gen) return; + q.readers[pane] = null; + var i: usize = 0; + while (i < q.len) { + const msg = q.items[(q.head + i) % q.items.len]; + const matches = switch (msg) { + .output => |o| o.pane == pane and o.gen == gen, + .eof => |e| e.pane == pane and e.gen == gen, + else => false, + }; + if (matches) q.removeAt(i).deinit(q.gpa, q.lsp_allocator) else i += 1; + } + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + } + + fn pushOutput(q: *Queue, pane: u8, gen: u32, bytes: []u8) bool { + q.lock(); + while (!q.closed and q.readers[pane] == gen and q.len >= output_capacity) { + q.waiting += 1; + std.debug.assert(libc.pthread_cond_wait(&q.space, &q.mutex) == .SUCCESS); + q.waiting -= 1; + } + if (q.closed or q.readers[pane] != gen) { + q.unlock(); + q.gpa.free(bytes); + return false; + } + q.items[(q.head + q.len) % q.items.len] = .{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }; + q.len += 1; + q.unlock(); + q.wake(); + return true; } fn removeAt(q: *Queue, offset: usize) Msg { @@ -999,54 +901,64 @@ const Queue = struct { return removed; } - /// Output and other refreshable work are lossy under sustained - /// backpressure. EOF and pipe completions are admitted by evicting queued - /// non-critical messages, so descriptors and futures reach the loop. + fn discardLsp(q: *Queue) void { + for (q.completions[0..q.completion_len], 0..) |msg, i| if (msg == .lsp) { + msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len -= 1; + std.mem.copyForwards(Msg, q.completions[i..q.completion_len], q.completions[i + 1 .. q.completion_len + 1]); + return; + }; + } + fn push(q: *Queue, m: Msg) void { + if (m == .output) { + _ = q.pushOutput(m.output.pane, m.output.gen, m.output.bytes); + return; + } q.lock(); if (q.closed) { - q.mutex.unlock(); + q.unlock(); m.deinit(q.gpa, q.lsp_allocator); return; } - if (q.len == q.items.len) { - const incoming_critical = switch (m) { - .pipe, .eof => true, - else => false, - }; - if (!incoming_critical) { - q.mutex.unlock(); - m.deinit(q.gpa, q.lsp_allocator); - return; - } - var offset: usize = 0; - while (offset < q.len) : (offset += 1) { - const queued_critical = switch (q.items[(q.head + offset) % q.items.len]) { - .pipe, .eof => true, - else => false, - }; - if (!queued_critical) break; - } - if (offset == q.len) { - q.mutex.unlock(); - m.deinit(q.gpa, q.lsp_allocator); - return; - } - q.removeAt(offset).deinit(q.gpa, q.lsp_allocator); - } - q.items[(q.head + q.len) % q.items.len] = m; - q.len += 1; - q.mutex.unlock(); - if (q.sdl_wake) { - var sev = std.mem.zeroes(c.SDL_Event); - sev.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&sev); + switch (m) { + .files_changed => q.files_changed = true, + .lsp, .pipe => { + if (m == .lsp) { + if (q.lsp_id != m.lsp.id) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.discardLsp(); + } + std.debug.assert(q.completion_len < q.completions.len); + q.completions[q.completion_len] = m; + q.completion_len += 1; + }, + else => { + if (m == .eof) { + if (q.readers[m.eof.pane] != m.eof.gen) { + q.unlock(); + return; + } + std.debug.assert(q.len < q.items.len); + } else if (q.len >= output_capacity) { + q.unlock(); + m.deinit(q.gpa, q.lsp_allocator); + return; + } + q.items[(q.head + q.len) % q.items.len] = m; + q.len += 1; + }, } + q.unlock(); + q.wake(); } fn take(q: *Queue) MessageBatch { q.lock(); - defer q.mutex.unlock(); + defer q.unlock(); var batch: MessageBatch = .{}; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; @@ -1055,139 +967,638 @@ const Queue = struct { q.len -= 1; } q.head = 0; + if (q.files_changed) { + batch.items[batch.len] = .files_changed; + batch.len += 1; + q.files_changed = false; + } + @memcpy(batch.items[batch.len..][0..q.completion_len], q.completions[0..q.completion_len]); + batch.len += q.completion_len; + q.completion_len = 0; + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); return batch; } - fn close(q: *Queue, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32) void { + fn discardCompletions(q: *Queue) void { + q.lock(); + defer q.unlock(); + q.lsp_id = null; + for (q.completions[0..q.completion_len]) |msg| msg.deinit(q.gpa, q.lsp_allocator); + q.completion_len = 0; + } + + fn close(q: *Queue) void { q.lock(); - defer q.mutex.unlock(); q.closed = true; + q.files_changed = false; + q.readers = @splat(null); + std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); + q.unlock(); + q.discardCompletions(); + q.lock(); + defer q.unlock(); while (q.len > 0) { const m = q.items[q.head]; - switch (m) { - .eof => |e| { - _ = libc.close(e.fd); - if (gens[e.pane] == e.gen) { - if (ptys[e.pane]) |pt| if (pt.fd == e.fd) { - ptys[e.pane] = null; - }; - } - }, - else => m.deinit(q.gpa, q.lsp_allocator), - } + m.deinit(q.gpa, q.lsp_allocator); q.head = (q.head + 1) % q.items.len; q.len -= 1; } q.head = 0; } + + fn deinit(q: *Queue) void { + q.close(); + std.debug.assert(q.waiting == 0); + std.debug.assert(libc.pthread_cond_destroy(&q.space) == .SUCCESS); + std.debug.assert(libc.pthread_mutex_destroy(&q.mutex) == .SUCCESS); + } +}; + +test "GUI completion survives a full output queue" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 7 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + for (0..PipeTasks.capacity) |id| queue.push(.{ .pipe = .{ + .id = @intCast(id), + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "pipe failure") }, + } }); + queue.push(.{ .lsp = .{ .id = 7, .rows = try gpa.dupe(u8, "completion") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + PipeTasks.capacity + 1, batch.len); + for (batch.items[0..output_capacity]) |msg| { + try std.testing.expect(msg == .output); + try std.testing.expectEqualStrings("output", msg.output.bytes); + } + for (batch.items[output_capacity..][0..PipeTasks.capacity], 0..) |msg, id| { + try std.testing.expect(msg == .pipe); + try std.testing.expectEqual(@as(u32, @intCast(id)), msg.pipe.id); + } + var found = false; + for (batch.slice()) |msg| if (msg == .lsp) { + try std.testing.expectEqual(@as(u32, 7), msg.lsp.id); + try std.testing.expectEqualStrings("completion", msg.lsp.rows.?); + found = true; + }; + try std.testing.expect(found); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +const PtyTests = struct { + fn waitBlocked(queue: *Queue) !void { + const until = shellClock() + 2_000; + while (shellClock() < until) { + queue.lock(); + const waiting = queue.waiting; + queue.unlock(); + if (waiting != 0) return; + try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + return error.ReaderDidNotBlock; + } + + fn tail(queue: *Queue, bytes: []u8) void { + if (queue.pushOutput(0, 1, bytes)) queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + } }; -/// The registered `lsp.setStatusSink` target, called from the protocol -/// client's reader threads: dupe with the concurrent lsp allocator, push to -/// the mutex queue. A push after close is disposed by the queue itself. +test "GUI PTY backpressure retains byte order and tail before EOF" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "tail"); + var thread: ?std.Thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + if (thread) |owned| owned.join(); + } + try PtyTests.waitBlocked(&queue); + queue.push(.files_changed); + queue.push(.files_changed); + var first = queue.take(); + defer for (first.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(output_capacity + 1, first.len); + for (first.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(first.items[output_capacity] == .files_changed); + thread.?.join(); + thread = null; + var last = queue.take(); + defer for (last.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), last.len); + try std.testing.expectEqualStrings("tail", last.items[0].output.bytes); + try std.testing.expect(last.items[1] == .eof); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); +} + +test "GUI PTY backpressure cancellation and close release owned producers" { + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |close| { + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + const tail = try gpa.dupe(u8, "cancelled"); + const thread = std.Thread.spawn(.{}, PtyTests.tail, .{ &queue, tail }) catch |err| { + gpa.free(tail); + return err; + }; + defer { + queue.cancelReader(0, 1); + thread.join(); + } + try PtyTests.waitBlocked(&queue); + if (close) queue.close() else queue.cancelReader(0, 1); + if (!close) { + queue.acceptReader(0, 2); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, 2, try gpa.dupe(u8, "replacement"))); + queue.push(.{ .eof = .{ .pane = 0, .gen = 2 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 2), batch.len); + try std.testing.expectEqualStrings("replacement", batch.items[0].output.bytes); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].eof.gen); + } else try std.testing.expectEqual(@as(usize, 0), queue.take().len); + } +} + +test "GUI PTY EOF reserve cannot evict terminal output" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + for (0..pardes.MAX_PANES) |pane| queue.acceptReader(@intCast(pane), 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "before") } }); + for (0..pardes.MAX_PANES) |pane| queue.push(.{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(queue_capacity, batch.len); + for (batch.items[0..output_capacity]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + for (batch.items[output_capacity..batch.len], 0..) |msg, pane| try std.testing.expectEqual(pane, msg.eof.pane); +} + +test "GUI PTY reader delivers a real shell tail before EOF and joins on close" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf '\\120\\101\\122\\104\\105\\123\\055\\124\\101\\111\\114'; exit\n")); + var bytes: std.ArrayList(u8) = .empty; + defer bytes.deinit(gpa); + var eof = false; + const until = shellClock() + 2_000; + while (!eof and shellClock() < until) { + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + for (batch.slice()) |msg| switch (msg) { + .output => |o| { + try std.testing.expect(!eof); + try bytes.appendSlice(gpa, o.bytes); + }, + .eof => |e| { + try std.testing.expect(e.failure == null); + eof = true; + }, + else => return error.UnexpectedMessage, + }; + if (!eof) try std.testing.io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(eof); + try std.testing.expect(std.mem.indexOf(u8, bytes.items, "PARDES-TAIL") != null); + shell.closePty(0); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expect(ptys[0] == null or ptys[0].?.reader == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); +} + +test "GUI PTY Restore joins a real reader waiting for queue space" { + const gpa = std.testing.allocator; + var core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + try core.dumpState(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + queue.acceptReader(1, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ .pane = 1, .gen = 1, .bytes = try gpa.dupe(u8, "other pane") } }); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + try PtyTests.waitBlocked(&queue); + const old_serial = core.panes[0].?.serial; + const replacement = try core.restore(core.dump_out.?); + shell.stopPtys(); + core.deinit(); + core = replacement; + shell.core = replacement; + try std.testing.expect(core.panes[0].?.serial != old_serial); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + queue.cancelReader(1, 1); + queue.acceptReader(0, gens[0]); + queue.push(.{ .eof = .{ .pane = 0, .gen = 1 } }); + try std.testing.expect(queue.pushOutput(0, gens[0], try gpa.dupe(u8, "fresh"))); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh", batch.items[0].output.bytes); +} + +test "GUI PTY deletion joins an idle reader and retains only its owned child for reaping" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var shell: Shell = undefined; + shell.core = core; + shell.gpa = gpa; + shell.ptys = &ptys; + shell.gens = &gens; + shell.queue = &queue; + shell.retired_shells = @splat(.{}); + defer shell.shutdownPtys(); + const rcs: host_io.Shell.PromptFiles = .{}; + const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null); + ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial }; + try spawnReader(gpa, &ptys[0].?, 0, 1, &queue); + try core.removePane(0); + shell.reconcilePtys(); + try std.testing.expect(queue.readers[0] == null); + try std.testing.expectEqual(@as(usize, 0), queue.waiting); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); + try std.testing.expectEqual(@as(c_int, -1), libc.fcntl(child.file.handle, libc.F.GETFD)); + for (shell.retired_shells) |retired| try std.testing.expect(retired.pid == 0 or retired.pid == child.pid); + shell.shutdownPtys(); + try std.testing.expectEqual(@as(libc.pid_t, -1), libc.waitpid(child.pid, null, libc.W.NOHANG)); + try std.testing.expectEqual(libc.E.CHILD, libc.errno(-1)); +} + +test "GUI PTY file watcher stops and joins without closing its watched descriptor" { + if (!file_watch.supported) return error.SkipZigTest; + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + const fd = file_watch.init(true); + if (fd < 0) return error.WatchInitFailed; + defer _ = libc.close(fd); + const stop = try stopPipe(); + defer for (stop) |pipe_fd| { + _ = libc.close(pipe_fd); + }; + const thread = try std.Thread.spawn(.{}, watchThread, .{ fd, stop[0], &queue }); + _ = host_io.writeFd(stop[1], "x"); + thread.join(); + try std.testing.expect(libc.fcntl(fd, libc.F.GETFD) >= 0); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + +test "GUI completion failure survives backlog and leaves the document unchanged" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + core.lspRequest(core.active, .completion, ""); + const id = core.lsp_wait.?.id; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = id }; + defer queue.deinit(); + queue.acceptReader(0, 1); + for (0..output_capacity) |_| queue.push(.{ .output = .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + } }); + queue.push(.{ .lsp = .{ .id = id, .rows = null } }); + var gens: [pardes.MAX_PANES]u32 = @splat(1); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + var shell: Shell = undefined; + shell.core = core; + shell.queue = &queue; + shell.gpa = gpa; + shell.lsp_allocator = gpa; + shell.gens = &gens; + shell.ptys = &ptys; + shell.retired_shells = @splat(.{}); + shell.saw_event = false; + shell.drainQueue(); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(shell.saw_event); +} + +test "GUI completions retain their arrival order across LSP and pipes" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 2 }; + defer queue.deinit(); + queue.push(.{ .pipe = .{ .id = 1, .success = true, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 2, .rows = try gpa.dupe(u8, "second") } }); + queue.push(.{ .pipe = .{ .id = 3, .success = false, .outputs = &.{} } }); + queue.push(.{ .lsp = .{ .id = 1, .rows = try gpa.dupe(u8, "late") } }); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 3), batch.len); + try std.testing.expectEqual(@as(u32, 1), batch.items[0].pipe.id); + try std.testing.expectEqual(@as(u32, 2), batch.items[1].lsp.id); + try std.testing.expectEqual(@as(u32, 3), batch.items[2].pipe.id); +} + +test "GUI completion reset frees queued payloads and rejects late LSP workers" { + const gpa = std.testing.allocator; + var workers: LspWorkers = .{}; + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = gpa, .lsp_workers = &workers, .sdl_wake = false, .lsp_id = 9 }; + defer queue.deinit(); + queue.acceptReader(0, 1); + queue.push(.{ .output = .{ .pane = 0, .gen = 1, .bytes = try gpa.dupe(u8, "retained") } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "current") } }); + queue.push(.{ .lsp = .{ .id = 8, .rows = try gpa.dupe(u8, "late old request") } }); + try std.testing.expectEqualStrings("current", queue.completions[0].lsp.rows.?); + for (0..PipeTasks.capacity) |id| { + const outputs = try gpa.alloc([]u8, 1); + outputs[0] = try gpa.dupe(u8, "cancelled result"); + queue.push(.{ .pipe = .{ .id = @intCast(id), .success = true, .outputs = outputs } }); + } + queue.discardCompletions(); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late before Restore") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("retained", batch.items[0].output.bytes); + + queue.lsp_id = 10; + queue.push(.{ .lsp = .{ .id = 10, .rows = null } }); + queue.push(.{ .lsp = .{ .id = 9, .rows = try gpa.dupe(u8, "late after Restore") } }); + var next = queue.take(); + defer for (next.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), next.len); + try std.testing.expectEqual(@as(u32, 10), next.items[0].lsp.id); + try std.testing.expect(next.items[0].lsp.rows == null); + + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "closing result") } }); + queue.push(.{ .pipe = .{ + .id = 17, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "closing failure") }, + } }); + queue.close(); + queue.push(.{ .lsp = .{ .id = 10, .rows = try gpa.dupe(u8, "late closed result") } }); + queue.push(.{ .pipe = .{ + .id = 18, + .success = false, + .outputs = &.{}, + .failure = .{ .stderr = try gpa.dupe(u8, "late closed failure") }, + } }); + try std.testing.expectEqual(@as(usize, 0), queue.take().len); +} + fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); const copy = q.lsp_allocator.dupe(u8, text) catch return; q.push(.{ .lsp_status = copy }); } -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, stop: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; + var failure: ?anyerror = null; while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + failure = error.PollFailed; + break; + } + if (fds[0].revents != 0) return; + if (fds[1].revents == 0) continue; const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; - break; // EIO when the child exits: treat as EOF + if (libc.errno(n) != .IO) failure = error.ReadFailed; + break; } if (n == 0) break; - const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); + const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch |err| { + failure = err; + break; + }; + if (!q.pushOutput(pane, gen, bytes)) return; } - q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); + q.push(.{ .eof = .{ .pane = pane, .gen = gen, .failure = failure } }); } -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; - th.detach(); +fn stopPipe() ![2]c_int { + var fds: [2]c_int = undefined; + if (libc.pipe(&fds) != 0) return error.PipeFailed; + errdefer for (fds) |fd| { + _ = libc.close(fd); + }; + for (fds) |fd| if (libc.fcntl(fd, libc.F.SETFD, @as(c_int, 1)) < 0) return error.PipeFailed; + return fds; } -/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the -/// events: the loop re-reads every watched pane anyway, so the only thing an -/// event carries that we need is THAT something happened, and parsing would -/// mean sharing the watch table with the thread that mutates it. Detached like -/// the pty readers, and ended the same way — teardown closes the fd, the read -/// fails, the thread returns. -fn watchThread(fd: c_int, q: *Queue) void { - // A kqueue cannot be read, so the macos arm parks in kevent(2) instead and - // is released by the teardown's `file_watch.stop`. See file_watch.wait. - if (comptime builtin.os.tag != .linux) { - while (file_watch.wait(fd)) q.push(.files_changed); - return; - } - var buf: [4096]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - break; - } - if (n == 0) break; - q.push(.files_changed); - } +fn spawnReader(gpa: std.mem.Allocator, pt: *Pty, pane: u8, gen: u32, q: *Queue) !void { + std.debug.assert(pt.reader == null and pt.fd >= 0); + const stop = try stopPipe(); + errdefer for (stop) |fd| { + _ = libc.close(fd); + }; + q.acceptReader(pane, gen); + errdefer q.cancelReader(pane, gen); + pt.reader = try std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, stop[0], pane, gen, q }); + pt.stop = stop; } -/// Block on the nested-instance socket and hand the loop each command line a -/// pardes started inside this one sends. Detached like the pty readers and the -/// watcher — but NOT ended the way they are: close(2) does not release a -/// thread parked in accept4 on linux, so this one simply dies with the -/// process. The window that leaves is one connection accepted between the last -/// drain and process exit pushing into a queue nobody empties again; Queue -/// frees a push made after close(), and the process is on its way out anyway. -fn lookThread(gpa: std.mem.Allocator, fd: c_int, q: *Queue) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(fd, &buf)) |line| { - const owned = gpa.dupe(u8, line) catch continue; - q.push(.{ .command = owned }); +fn watchThread(fd: c_int, stop: c_int, q: *Queue) void { + while (true) { + var fds = [_]libc.pollfd{ + .{ .fd = stop, .events = libc.POLL.IN, .revents = 0 }, + .{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }, + }; + if (libc.poll(&fds, fds.len, -1) < 0) { + if (libc.errno(-1) == .INTR) continue; + return; + } + if (fds[0].revents != 0) return; + if ((fds[1].revents & (libc.POLL.ERR | libc.POLL.HUP | libc.POLL.NVAL)) != 0) return; + if (fds[1].revents != 0 and file_watch.drain(fd)) q.push(.files_changed); } } -/// Answer a language query off the render loop and push the rows to the queue. -/// The snapshot and the query body are `lsp_host`'s; what stays here is this -/// shell's own plumbing — a detached thread, the refcount that teardown joins -/// on, and the mutex queue the pty readers already use. -fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *lsp_host.Job, q: *Queue) void { +fn lspThread(lsp_allocator: std.mem.Allocator, workers: *LspWorkers, job: *host_io.Lsp.Job, q: *Queue) void { defer workers.finish(); - lsp_host.work(lsp_allocator, job, q, pushLspRows); + host_io.Lsp.work(lsp_allocator, job, q, pushLspRows); } -fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn pushLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const q: *Queue = @ptrCast(@alignCast(ctx orelse return)); q.push(.{ .lsp = .{ .id = id, .rows = rows } }); } -/// Copy the query out of the core and hand it to a thread. A detached thread -/// per query is fine at this rate: one keystroke, one query, and the queue -/// already tolerates a late push after close. -fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_api.LspRequest) void { +fn spawnLsp(core: *pardes.Pardes, q: *Queue, e: host_io.Lsp.Request) void { const lsp_allocator = q.lsp_allocator; - const job = lsp_host.snapshot(lsp_allocator, core, e) orelse return; - q.lsp_workers.start(); - const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch { + q.lock(); + q.lsp_id = e.id; + q.discardLsp(); + q.unlock(); + if (!q.lsp_workers.start()) { + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", error.WorkersBusy); + } + const job = host_io.Lsp.snapshot(lsp_allocator, core, e) catch |err| { + q.lsp_workers.finish(); + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); + }; + const th = std.Thread.spawn(.{}, lspThread, .{ lsp_allocator, q.lsp_workers, job, q }) catch |err| { q.lsp_workers.finish(); job.free(lsp_allocator); - return; + core.update(.{ .lsp_resp = .{ .id = e.id, .rows = null } }); + return core.reportError(e.pane, "lsp", err); }; th.detach(); } +test "GUI LSP worker limit rejects before allocation and recovers after owned workers exit" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + pane.cur_col = 1; + var workers: LspWorkers = .{}; + var gate: std.Io.Event = .unset; + var threads: [LspWorkers.capacity]?std.Thread = @splat(null); + defer { + gate.set(std.testing.io); + for (threads) |thread| if (thread) |owned| owned.join(); + workers.wait(); + } + for (&threads) |*thread| { + try std.testing.expect(workers.start()); + thread.* = std.Thread.spawn(.{}, struct { + fn run(active: *LspWorkers, ready: *std.Io.Event) void { + defer active.finish(); + ready.waitUncancelable(std.testing.io); + } + }.run, .{ &workers, &gate }) catch |err| { + workers.finish(); + return err; + }; + } + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + try std.testing.expect(!workers.start()); + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var queue: Queue = .{ .gpa = gpa, .lsp_allocator = failing.allocator(), .lsp_workers = &workers, .sdl_wake = false }; + defer queue.deinit(); + core.lspRequest(core.active, .completion, ""); + const rejected = core.lsp_wait.?.id; + queue.lsp_id = rejected -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "obsolete queued result") } }); + spawnLsp(core, &queue, .{ + .id = rejected, + .kind = .completion, + .pane = @intCast(core.active), + .offset = 1, + .arg = "", + }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "WorkersBusy") != null); + try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(rejected, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = rejected -% 1, .rows = try gpa.dupe(u8, "obsolete late result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + try std.testing.expectEqual(@as(usize, LspWorkers.capacity), workers.active.load(.monotonic)); + + gate.set(std.testing.io); + workers.wait(); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + queue.lsp_allocator = gpa; + core.lspRequest(core.active, .status, ""); + const accepted = core.lsp_wait.?.id; + spawnLsp(core, &queue, .{ + .id = accepted, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }); + workers.wait(); + var batch = queue.take(); + defer for (batch.slice()) |msg| msg.deinit(gpa, gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqual(accepted, batch.items[0].lsp.id); + try std.testing.expect(batch.items[0].lsp.rows != null); + core.update(.{ .lsp_resp = .{ .id = accepted, .rows = batch.items[0].lsp.rows } }); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqualStrings("abc", pane.file.?.content); +} + fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { defer job.deinit(gpa); const response = selection_pipe.runJob(gpa, io, job); q.push(.{ .pipe = response }); } -/// Copy every borrowed core byte before the tracked worker starts. The queue -/// owns the response and already has close-time disposal for a late answer. fn spawnPipe( core: *pardes.Pardes, io: std.Io, @@ -1201,15 +1612,78 @@ fn spawnPipe( return; } const view = core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(gpa, view) catch return; - const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { + const job = selection_pipe.Job.copy(gpa, view) catch |err| { + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); + }; + const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch |err| { job.deinit(gpa); - return; + core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return core.reportError(core.active, "pipe", err); }; std.debug.assert(tasks.add(.{ .id = id, .future = future })); } -// ---- the renderer state ---- +test "GUI worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var workers: LspWorkers = .{}; + var queue: Queue = .{ + .gpa = gpa, + .lsp_allocator = failing.allocator(), + .lsp_workers = &workers, + .sdl_wake = false, + }; + defer queue.deinit(); + var tasks: PipeTasks = .{}; + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + if (failure == 0) { + queue.lsp_id = req.id -% 1; + queue.push(.{ .lsp = .{ .id = queue.lsp_id.?, .rows = try gpa.dupe(u8, "old queued result") } }); + spawnLsp(core, &queue, req); + try std.testing.expectEqual(req.id, queue.lsp_id.?); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + queue.push(.{ .lsp = .{ .id = req.id -% 1, .rows = try gpa.dupe(u8, "late old result") } }); + try std.testing.expectEqual(@as(usize, 0), queue.completion_len); + } else { + var shell: Shell = undefined; + shell.core = core; + shell.threads_ok = false; + lsp(&shell, req); + } + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + spawnPipe(core, failing_io, failing.allocator(), &queue, &tasks, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), workers.active.load(.monotonic)); + try std.testing.expectEqual(@as(usize, 0), tasks.len); + try std.testing.expectEqual(@as(usize, 0), queue.len); + } +} const Gui = struct { window: *c.SDL_Window, @@ -1237,21 +1711,11 @@ const Gui = struct { prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, font: *c.UIFont, - /// the file behind `font`, when it is one the Font builtin loaded. Empty - /// for the font the binary ships with, which is @embedFile'd and not ours - /// to free — FreeType borrows these bytes for the face lifetime. font_bytes: []u8 = &.{}, font_name: [255]u8 = @splat(0), font_name_len: u8 = 0, - /// Faces are discovered and opened once at startup. `glyphs` below caches - /// the raster result by codepoint, so fallback probing happens once per - /// glyph/atlas epoch rather than once per cell or frame. fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null), fallback_count: usize = 0, - /// the cell height the metrics are asked for, in pixels. A field and not - /// the local constant it used to be because refitFont reads it: changing - /// the FACE has to re-ask at the same size, and changing the SIZE (the - /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. px: f32, scale: f32, tagline_scale: f32, @@ -1263,9 +1727,6 @@ const Gui = struct { ascent: i32, tagline_baseline: i32, - // glyph atlas: CPU staging bitmap + codepoint/role → texel slot, pen-walk alloc. - // The slot is also the fallback-resolution cache: after first rasterization - // every cell/frame takes the hash hit without probing any face again. atlas_stage: []u8, glyphs: std.AutoHashMap(GlyphKey, Slot), pen_x: u32 = 0, @@ -1277,16 +1738,6 @@ const Gui = struct { live_ctrl: bool = false, live_alt: bool = false, - // Fractional wheel scroll, one pane at a time. SDL's exact floating-point - // distance is batched until the next render. The core still moves only at - // whole-row boundaries; scroll_lag retains the sub-row picture position. - // - // ponytail: one accumulator, so exactly one pane can be offset and only the - // MOUSE wheel fills it — the deck's left stick and a two-finger touch - // scroll still hand the core their whole rows on the spot (they have - // their own sub-tick accumulators, and neither aims well enough to miss - // the fractional rendering). Both are one call site each: point them at - // scroll_delta the way the wheel arm of dispatch does. scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_body_y: u16 = 0, // that rect's first BODY row (Tagbottom moves it) @@ -1297,26 +1748,16 @@ const Gui = struct { scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, - // Crt builtin: the scene renders into this texture, then a fullscreen - // CRT pass warps it onto the real target scene_tex: ?*c.SDL_GPUTexture = null, scene_tex_w: u32 = 0, scene_tex_h: u32 = 0, - /// Resize-time scene target creation can fail transiently. Present the - /// direct frame meanwhile, then stop retrying after a small bounded run. scene_failures: u8 = 0, scene_target_failed: bool = false, - /// Exact postprocess state used by the last submitted frame. Input maps - /// through this snapshot, not through config/time sampled a frame later. presented_scene: crt.Frame = .{}, - /// Physical pointer state is retained separately from its mapped grid - /// cell. Ripple/glitch can move the displayed source under a stationary - /// hand, so every accepted scene frame remaps this same window point. pointer_present: bool = false, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, - // PARDES_TEST frame capture (render into an offscreen target, dump PPM) capture: bool = false, capture_dir: []const u8 = "", capture_tex: ?*c.SDL_GPUTexture = null, @@ -1325,28 +1766,13 @@ const Gui = struct { capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, - // Software present. A Vulkan swapchain needs a presentable surface, which - // a compositor without linux-dmabuf cannot provide (p9wl and other - // software/remote Wayland stacks: the driver reports "this surface does - // not support presenting"). The GPU still renders, so render offscreen - // exactly like capture does and blit the readback through SDL_Renderer, - // which goes out over wl_shm. - // - // `config.gui_transparent` takes the same path deliberately rather than by - // failure: SDL's GPU API refuses to claim a transparent window at all, and - // SDL_Renderer is the presenter that does honour one. soft_present: bool = false, - /// The window was created with SDL_WINDOW_TRANSPARENT, so a themeless - /// ground is nothing at all instead of `bg_default`. Implies - /// `soft_present`; read per frame by `ground`. transparent: bool = false, soft_renderer: ?*c.SDL_Renderer = null, soft_texture: ?*c.SDL_Texture = null, soft_tex_w: u32 = 0, soft_tex_h: u32 = 0, - // Steam Deck: gamepad-driven virtual cursor in SDL window coordinates. - // Conversion to physical render pixels happens once in mouseCell. gamepad: ?*c.SDL_Gamepad = null, pad_x: f32 = 0, pad_y: f32 = 0, @@ -1356,10 +1782,6 @@ const Gui = struct { }; fn setGuiFontName(g: *Gui, fallback: []const u8) void { - // Config says "effective", so ask the rasterizer what it accepted rather - // than echoing the picker label (a filename stem which need not be the - // face's own identity). Some old/synthetic faces have neither a - // PostScript nor family name; only those retain the known-good label. const name = if (c.ui_font_name(g.font)) |name_z| std.mem.span(name_z) else fallback; const len = @min(name.len, g.font_name.len); @memcpy(g.font_name[0..len], name[0..len]); @@ -1381,8 +1803,6 @@ fn acknowledgeGuiFont(g: *const Gui, core: *pardes.Pardes) void { } fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { - // The shipped face is the first fallback whenever Font selects a narrower - // user face. A second FT_Face is cheap and keeps both lifetimes independent. if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| { g.fallbacks[0] = .{ .face = face }; g.fallback_count = 1; @@ -1392,7 +1812,7 @@ fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { defer arena_state.deinit(); for (fonts.fallbacks(arena_state.allocator())) |candidate| { if (g.fallback_count == g.fallbacks.len) break; - const bytes = look.readFile(gpa, candidate.path) catch continue; + const bytes = filesystem.readFile(gpa, candidate.path) catch continue; const len = std.math.cast(c_int, bytes.len) orelse { gpa.free(bytes); continue; @@ -1420,11 +1840,9 @@ fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { const face = loaded.?.face; if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face; } - // Preserve FreeType's useful .notdef box when no face has the codepoint. return g.font; } -/// A cell's on-screen rect: exactly cell_w×cell_h at (0,0). const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; const MouseCell = struct { col: u16, row: u16 }; const WindowGeometry = struct { @@ -1449,12 +1867,6 @@ fn windowGeometry(window: *c.SDL_Window) WindowGeometry { }; } -/// This window in whole cells, which is the grid the core is asked to be. The -/// one derivation of it: `pollFrame` follows the window with it every frame, -/// `refitFont` re-asks after Ctrl+/Ctrl- has moved the cell under it, and both -/// attach paths tell the session what this window can show with it. A window -/// that is not a whole number of cells across has to round the same way in all -/// four places or the last row lands off the bottom edge. const GridCells = struct { cols: u16, rows: u16 }; fn windowCells(g: *const Gui) GridCells { @@ -1494,47 +1906,27 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout { return .{ .w = tag_w, .h = @floatFromInt(g.cell_h), - // emitInstance still receives the canonical surface column. Offset - // the smaller grid so its column zero is the pane's physical left. .x_off = origin_col * (body_w - tag_w), .y_off = 0, }; } -/// Where a tagline cell's compact band begins. The origin rule itself is -/// `pardes.taglineOriginCol` — moved to the core so the AppKit shell can call -/// the SAME rule over the C ABI instead of advancing its tag rows on body -/// pitch, which is the second copy of this that already went wrong once (see -/// `taglineBandOffset`). -/// -/// `core` is null in an attached window, and then EVERY tagline cell takes the -/// last line's fallback: the wire carries cells, not the pane rects that placed -/// them, so there is no band origin to compact against. That is the same answer -/// `gridCellAtDimensions` reaches for the same reason, which is what keeps the -/// two honest — a click lands on the glyph it was aimed at, because both sides -/// map through the body grid. The visible cost is one tagline row's worth of -/// loose tracking. fn taglineLayoutForCell( g: *const Gui, core: ?*const pardes.Pardes, col: u16, row: u16, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, ) CellLayout { if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0); const p = core orelse return compactTaglineLayout(g, @floatFromInt(col)); return compactTaglineLayout(g, pardes.taglineOriginCol(p, col, row, track)); } -/// `panel_animation.Box.contains` under this file's older name. Kept as an -/// alias rather than renamed at three call sites so the predicate has exactly -/// one definition — it was a fourth copy of the same half-open cell test the -/// core, `taglineOriginCol` and ScenePostprocessor.swift all make. -const boxContains = pardes.panel_animation.Box.contains; +const boxContains = pardes.layout.Box.contains; -// EFFECT_CODE_PANEL_HOST_BEGIN const PaintBatch = struct { - track: ?pardes.panel_animation.Track = null, + track: ?pardes.layout.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, @@ -1542,21 +1934,15 @@ const PaintBatch = struct { }; const PaintPlan = struct { - // One static batch plus live tracks and presentation-only closing - // tombstones. Slot reuse can legitimately expose both for one pane id. batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), len: usize = 1, }; -/// Painter order shared by cells and pixel attachments. Core hit testing walks -/// the reverse order, so the visually top panel receives the click too. -fn makePaintPlan(tracks: []const pardes.panel_animation.Track, has_diff: bool) PaintPlan { +fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan { var plan: PaintPlan = .{}; - for ([_]pardes.panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!track.active() or track.phase != phase) continue; - // These effects have no honest fallback without the frozen grid. - // Render the canonical frame rather than materializing garbage. if (track.effect.needsPreviousGrid() and !has_diff) continue; std.debug.assert(plan.len < plan.batches.len); plan.batches[plan.len].track = track; @@ -1580,21 +1966,21 @@ fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { return 0; } -fn panelCellCoord(track: pardes.panel_animation.Track, col: u16, row: u16) u32 { +fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y))); return @as(u32, row -| y0) << 16 | @as(u32, col -| x0); } -fn panelGridSize(track: pardes.panel_animation.Track) u32 { +fn panelGridSize(track: pardes.layout.Track) u32 { const source = track.contentBox(); const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w)))); const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h)))); return @as(u32, rows) << 16 | @as(u32, cols); } -fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { +fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { const px0 = layout.x_off + box.x * layout.w; const py0 = layout.y_off + box.y * layout.h; const px1 = px0 + box.w * layout.w; @@ -1609,7 +1995,7 @@ fn ndcBox(box: pardes.panel_animation.Box, layout: CellLayout, win_w: f32, win_h fn setTransitionFields( instance: anytype, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, layout: CellLayout, win_w: f32, win_h: f32, @@ -1645,24 +2031,12 @@ fn setTransitionFields( instance.serial = active.serial; instance.cell_coord = cell_coord; } -// EFFECT_CODE_PANEL_HOST_END - -// ===================================================================== -// entry -// ===================================================================== pub const run = runNative; -/// `attach` is `--attach[=]`: empty means "the session there is" (see -/// `detached_client.resolve`). It is a parameter rather than an `Options` field -/// because it says nothing to the core — this process does not have one when it -/// is set. fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void { const gpa = init.gpa; const env = init.environ_map; - // PARDES_TEST_GRID owns the process when it is set, and it is headless. - // There is no window to hand to a session, so refuse the combination - // rather than silently dropping the flag a harness meant. if (env.get("PARDES_TEST_GRID") != null) { if (attach != null) { log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{}); @@ -1678,14 +2052,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.SdlInit; } - // The trackpads only exist through SDL's built-in HIDAPI Steam Deck - // driver (it registers the two touchpads and disables "lizard mode"). - // It's default-on on Linux; pin it so intent is explicit. NOTE: this is - // NOT enough in Game Mode — Steam Input there hands the app a - // touchpad-less virtual gamepad instead of the real Neptune controller, - // so the pads go dead no matter what the app does. The only fix is to - // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), - // after which the real controller enumerates and the touchpad events flow. _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { @@ -1694,11 +2060,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - // A see-through buffer, so a theme with no background of its own shows the - // compositor's backdrop instead of `bg_default`. Asked for at CREATION - // because that is the only time it can be: X11 picks the 32-bit visual - // here, and the Wayland backend decides here whether to keep an opaque - // region on the surface. if (config.gui_transparent) win_flags |= c.SDL_WINDOW_TRANSPARENT; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); @@ -1707,28 +2068,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); - // Keep the window's mouse ungrabbed: desktop users must be able to move - // the pointer out normally. The deck's virtual pointer is clamped and - // warped explicitly only when its controls move it (see dispatch and - // pollGamepad), so it does not need window-wide confinement. const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; - // A failed claim is not fatal: it means the compositor has no presentable - // Vulkan surface (no linux-dmabuf), which is the normal case under p9wl and - // other software/remote Wayland compositors. Rendering still works, so keep - // the device and present the readback through SDL_Renderer instead. - // PARDES_SOFT_PRESENT=1 takes that path on a compositor that could present, - // which is how the path is exercised without a remote display. - // - // A transparent window does not even attempt the claim. It is not a - // compositor's shortcoming and there is nothing to retry: SDL_gpu.c fails - // SDL_ClaimWindowForGPUDevice for SDL_WINDOW_TRANSPARENT unconditionally, - // because D3D12 has no transparent swapchain and the API says no - // everywhere rather than only where it must. SDL_Renderer's own vulkan and - // opengl backends do honour one, and that is the presenter this path - // already had. var soft_present = false; var soft_renderer: ?*c.SDL_Renderer = null; const force_soft = if (env.get("PARDES_SOFT_PRESENT")) |raw| @@ -1748,7 +2091,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u }; soft_present = true; } - // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync const present_mode: c.SDL_GPUPresentMode = blk: { if (soft_present) break :blk c.SDL_GPU_PRESENTMODE_VSYNC; if (env.get("PARDES_SDL_PRESENT")) |raw| { @@ -1771,15 +2113,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u if (!soft_present) { _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); } - // Without a claimed window there is no swapchain format to ask for, so - // pick a colour-target format the device does support; the readback and - // the SDL_Texture agree on it below. const swapchain_format = if (soft_present) softTargetFormat(device) else c.SDL_GetGPUSwapchainTextureFormat(device, window); - // ---- font + cell metrics ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; @@ -1800,7 +2138,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u _ = c.SDL_SyncWindow(window); } - // ---- glyph atlas (R8) + pipelines ---- var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; @@ -1904,35 +2241,15 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; - // `--attach`: this process has a window and NO core. Everything above is - // the window and the rasterizer, which an attached frontend needs exactly - // as much as a whole session does; everything below is the core, which - // lives in the detached process (src/detached/). The branch is here so both - // leave by the same door — the GPU objects, the glyph atlas and the face - // are put away by the defers above whichever mode ran. if (attach) |requested| return attachRequested(gpa, &g, requested); - // ...and the same handover arrived at from the other side: the `Attach` - // builtin gives this window to a session mid-flight. `localSession` returns - // a CONNECTED client only, and by the time it does every pane shell, watch - // and mount of the local session is already away. var attached: ?detached_client.Client = null; try localSession(init, &g, opts_in, test_mode, &attached); if (attached) |*client| return attachedLoop(gpa, &g, client); } -/// The session that lives in THIS process: the core, its pane shells, its -/// watches, its acme filesystem and the loop that pumps them. A function of its -/// own rather than the tail of `runNative` because that makes its teardown a -/// scope exit instead of a second copy of the same twelve defers — and the -/// `Attach` builtin needs exactly that teardown, in exactly that LIFO order, -/// before an attached loop may draw on the same window. -/// -/// `attached` is how a connected client leaves: it is set only after a -/// handshake is in flight, which is what makes a failed `Attach` a no-op. fn localSession( init: std.process.Init, g: *Gui, @@ -1942,8 +2259,8 @@ fn localSession( ) !void { const io = init.io; const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); // stb_image allocator for image panes if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -1952,13 +2269,6 @@ fn localSession( if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } - // Live sessions initialize at the default 80x24 grid; the real window size - // arrives as a resize EVENT on the first loop pass. The core defers the - // shell greeting until after the first resize (so `ls` wraps to the real - // pane width) — pre-sizing at init would mean no resize ever fires and the - // greeting never runs (panes sat blank until the first interaction). - // Dump loads pre-size instead: replayed panes never greet, and sizing at - // init avoids reflowing their replayed content twice. var opts = opts_in; opts.image_allocator = allocs.image; opts.pdf_allocator = allocs.pdf; @@ -1972,30 +2282,20 @@ fn localSession( opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); } var core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); - // SDL is itself a native-pixel backend. This is deliberately set after - // construction: argv image panes no longer freeze the startup capability - // into their PETSCII preference, so their first render emits attachments. core.native_images = true; observeGuiFont(g, core); syncTaglineFont(g, core); - // PATH, the bash banner and the prompt rc files, in the one order that - // works. Children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2005,42 +2305,28 @@ fn localSession( }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // One watcher for every watched pane, opened here — before any thread - // exists — so the pre-loop drain below can already mark the file a - // positional path argument opened. `false`: this host parks a thread in it - // rather than polling it. -1 where there is no watcher to make: watchPane - // goes quiet and the core simply never gets a file_changed event. - var inotify_fd: c_int = file_watch.init(false); + var inotify_fd: c_int = file_watch.init(true); + var watch_reader: ?std.Thread = null; + var watch_stop: [2]c_int = .{ -1, -1 }; defer if (inotify_fd >= 0) { - // `stop` releases a kqueue wait (macos); the close ends the blocking - // read (linux). Both leave watchThread on its way out. + if (watch_reader) |thread| { + _ = host_io.writeFd(watch_stop[1], "x"); + thread.join(); + } + for (watch_stop) |fd| if (fd >= 0) { + _ = libc.close(fd); + }; file_watch.stop(inotify_fd); _ = libc.close(inotify_fd); inotify_fd = -1; }; var watches: file_watch.Table = @splat(null); - // The socket a pardes launched inside this one connects to (nested.zig). - // --nested opted out of the whole mechanism, including being an outer - // instance; so does any failure to bind, and then children simply open - // their own session. - const sock_fd: c_int = if (opts.nested) -1 else nested.listen(); - defer nested.unlisten(sock_fd); - - // `--fs`: mounted before the initial spawns (they are the shells that need - // PARDES_FS) and before any thread of ours exists (the mount forks the - // setuid fusermount3 helper). Null covers both "no --fs" and "--fs but the - // mount failed"; the second is reported on a message row inside `start` and - // the session runs on without a filesystem. Teardown answers everything - // held, aborts the connection, unmounts and removes `/`; the - // parent stays, like nested.zig's socket directory. - var fs = fs_service.start(gpa, core); - // Covers the error paths only: the ordinary exit unmounts at the END OF - // THE LOOP instead, see there. - defer if (fs) |f| f.deinit(); + var fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, @@ -2058,63 +2344,34 @@ fn localSession( .fs = fs, .test_mode = test_mode, }; + defer shell.shutdownPtys(); const host = shell.host(); - // `pump` installs this every pass; the pre-loop drain below happens - // outside one, so the initial spawns would otherwise reach the core's own - // virtual ptys instead of forking. core.host = host; - // initial spawns BEFORE any worker thread exists: forkpty from a - // multithreaded process can wedge the child before exec (see tty.zig). while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - // ...and the one file watcher. Started even with nothing marked yet: the fd - // already exists and an unwatched inotify instance just parks in read(2) — - // one thread for the process, however many panes come and go. - if (inotify_fd >= 0) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the nested-instance listener, detached like every other blocking - // worker here - if (sock_fd >= 0) if (std.Thread.spawn(.{}, lookThread, .{ gpa, sock_fd, &queue })) |th| th.detach() else |_| {}; - // ...and the /dev/fuse poller, which is the same kind of thread again — - // except joined by `Fs.deinit` rather than detached, because fuse.zig gives - // it a control pipe that CAN wake it out of poll(). - fs_service.wake(fs, &queue, wakeFs); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + if (inotify_fd >= 0) { + watch_stop = try stopPipe(); + watch_reader = try std.Thread.spawn(.{}, watchThread, .{ inotify_fd, watch_stop[0], &queue }); + } + if (fs) |f| try f.wakeThread(&queue, wakeFs); _ = c.SDL_StartTextInput(g.window); if (test_mode) setStdinRaw() catch {}; shell.threads_ok = true; - // Server state narration: reader threads → queue → drainQueue → the - // message row. Unset before the queue closes (see the defer above it). pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); - // The core owns the loop. This owns the two things a pump cannot do from - // inside itself, because both replace the whole session and are only safe - // BETWEEN iterations: Restore swaps the `Pardes`, and Attach retires it. while (!core.quit) { try core.pump(host); if (core.quit) break; // a session that ended does not restore into one - // Attach builtin: hand this window's screen to a detached session. - // - // GREET FIRST, SWAP SECOND, and that order IS the feature. - // `detached_client.attempt` resolves, connects AND waits for the - // `welcome`, and closes whatever it opened on every other outcome — so - // when this returns anything but `.greeted`, nothing below has run and - // this instance is exactly as it was: every pane, every shell, every - // unsaved buffer, the whole undo history. It says why on the row of the - // pane that ran the word and the session goes on. A half-torn-down - // editor is the one outcome an attach must never have, and `open` alone - // cannot rule it out — a `refuse .version` from a session built by the - // last `zig build` arrives AFTER the connect. if (core.takeAttach()) |req| { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows); switch (outcome) { .greeted => |client| { - // Greeted, so this session is over: the `break` runs the - // filesystem unmount below and then every defer above, and - // `runNative` picks the client up on the far side. attached.* = client; break; }, @@ -2124,24 +2381,19 @@ fn localSession( }, } } - // Restore builtin: swap in a core rebuilt from the dump; kill the live - // shells (their detached readers wake on child death; gens bumped so - // the stale eofs close the old fds without touching the replay panes) if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; + const bytes = filesystem.readFile(gpa, rp) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; defer gpa.free(bytes); - var o = core.opts; - o.cols = core.screen_w; - o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.kill(pt.pid, libc.SIG.KILL); - slot.* = null; + const nc = core.restore(bytes) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; }; - for (&gens) |*g2| g2.* +%= 1; - // the replay core's pane ids mean new things, and the dying core's - // `watch off` effects go into a queue nobody drains — drop the lot - // here. The new core emits its own `on`s as it builds its panes. + pipe_tasks.cancelAll(io); + queue.discardCompletions(); + shell.stopPtys(); for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( inotify_fd, &watches, @@ -2156,6 +2408,7 @@ fn localSession( g.prepared_images.clearRetainingCapacity(); nc.native_images = true; nc.host = host; + if (fs) |f| f.reset(core); core.deinit(); core = nc; shell.core = nc; @@ -2165,32 +2418,15 @@ fn localSession( } } - // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below: a - // session that has decided to exit must not spend its teardown holding a - // mount nobody is serving, so a client blocked on `/event` when the - // last pane is deleted through `ctl` gets ENOTCONN at once. if (fs) |f| { - f.deinit(); + f.deinit(gpa); fs = null; shell.fs = null; } } -// ===================================================================== -// --attach: a window, a socket, and no core -// ===================================================================== - -/// What to say when an attach did not happen. One function for both callers -/// because it is one set of outcomes: `--attach` logs it to a terminal it has -/// not drawn over yet, the `Attach` word puts it on the pane's message row, and -/// neither should be inventing its own wording for `refuse .version`. -/// -/// `requested` is the word a person typed, empty for "the session that is -/// there" — which is the whole difference between "no session called work" and -/// "nothing is detached". fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 { return switch (outcome) { - // The caller took this one and never asks. .greeted => unreachable, .no_session => if (requested.len != 0) std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch @@ -2210,12 +2446,6 @@ fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const }; } -/// `--attach[=]`: this window is a frontend from its first frame. Split -/// from `attachedLoop` because the two arrive with different evidence — a -/// command line has a person at a terminal to tell when there is nothing to -/// attach to and a process exit status to carry it, while an `Attach` inside a -/// session has a pane's message row and a live editor to leave standing. Both -/// reach `attachedLoop` with a GREETED client and never with less. fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void { const geom = windowCells(g); const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows); @@ -2225,13 +2455,8 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void return attachedLoop(gpa, g, &client); }, else => { - // The window exists but has drawn nothing, so stderr is still the - // only place a person is looking; the wording is the message row's, - // because it is the same set of outcomes. var mbuf: [256]u8 = undefined; log.err("{s}", .{attachFailure(&mbuf, outcome, requested)}); - // ...and the exit status keeps the distinction the sentence makes, - // for whatever launched this window. return switch (outcome) { .no_session => error.NoSession, .ambiguous => error.AmbiguousSession, @@ -2244,69 +2469,27 @@ fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void } } -/// The whole of an attached window: input and screen, and nothing else. SDL -/// events become `pardes.Event`s on the socket through the same `dispatch` a -/// local session uses; frames come back and go through the same `renderFrame`. -/// It forks no shell, writes no file and watches no path — the session process -/// does all of that now — so the only effects still arriving here are the three -/// that need a human's own display. fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void { - // The `bye` is a courtesy: the session survives a frontend that simply - // dies, but seven bytes turn "the peer vanished" into "the peer left" in - // its log. defer client.detach(); - // The window may have arrived here from `localSession`, where this was - // already called; SDL_StartTextInput is idempotent, and calling it is what - // makes the `--attach`-from-startup path receive SDL_EVENT_TEXT_INPUT at - // all. _ = c.SDL_StartTextInput(g.window); var in: Input = .{ .client = client }; - // A frame is the only thing that makes this window redraw. There is no - // animation clock and no core asking for a tick — the session spends both - // and sends the result — so a pass that saw nothing new presents nothing. var dirty = false; var geom = windowCells(g); while (true) { const link = client.wait(detached_client.poll_ms); - // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in the - // same call that read the last bytes, and the last bytes are the - // session's `quit`: `fill` appends every chunk and only then sees the - // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly - // this reason, and honouring it is what makes an ordinary `Kill` close - // every attached window by the front door instead of leaving whichever - // one lost the race reporting a broken link. while (true) { const msg = (try client.next()) orelse break; switch (msg) { - // A greeting cannot arrive twice and a refusal cannot follow - // one at all — `detached_client.attempt` consumed the welcome - // before this loop was entered, and the union is exhaustive, so - // these two arms exist to say that rather than to do anything. - // A session that sent either here is not speaking this protocol. .welcome => {}, .refuse => |why| { log.err("session refused an already-greeted frontend: {t}", .{why}); return error.Refused; }, - // Applied too — `grid` and `cursor` are current by the time - // this lands, so all that is left is putting them on screen. .frame => dirty = true, - // THE SESSION ENDED (`Kill`): every frontend goes with it. .quit => return, - // ...and `Detach`: THIS frontend was asked to leave and the - // session is carrying on without it, panes and shells and undo - // history intact, with whatever other frontends are attached - // still looking at it. Leaving because a person asked is a - // SUCCESS — hence a plain return and not the `error.Refused` - // above — and the deferred `client.detach()` still sends the - // `bye`, so the session logs a peer that left rather than one - // that vanished. The window closes because `runNative` returns. .detach => return, .set_clipboard => |text| putClipboard(gpa, text), - // The answer is not a reply message: it is an ordinary paste - // event on the way back, which is the same asynchronous shape - // `pull_read_clipboard` already has in process. .read_clipboard => if (takeClipboard()) |text| { defer c.SDL_free(text.ptr); in.post(.{ .paste = text }); @@ -2319,15 +2502,9 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); pollGamepad(g, &in); - // One check for the whole burst rather than one per event: `Input.post` - // stops sending at the first failure, so this is where a dead link is - // reported and there is nothing left in flight to lose. if (in.lost) |err| return err; if (in.quit) return; - // What this WINDOW can show, which is not a promise about the next - // frame: with several frontends attached the session grid is the - // smallest common one (client.zig GEOMETRY). const now = windowCells(g); if (now.cols != geom.cols or now.rows != geom.rows) { geom = now; @@ -2339,17 +2516,6 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } } -/// The frame the session sent, through the renderer this window already has. -/// The `Surface` is built OVER the client's grid rather than copied into one: -/// `renderFrame` reads cells and never writes them, and a full frame of a large -/// grid is 1.6 MiB. -/// -/// Three of a session's own surface fields are absent here and each absence is -/// load-bearing. No panel tracks: a pane transition is composed by the process -/// that owns the panes and what arrives is the composed result, so `makePaintPlan` -/// builds its single static batch. No pixel attachments: this wire carries no -/// images. No previous cells: `hasPanelDiff` is therefore false and the whole -/// old/new layer machinery stays out of the plan. fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { var surface: pardes.Surface = .{ .cols = client.cols, @@ -2357,28 +2523,15 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien .cells = client.grid.items, .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, }; - // Two of `renderFrame`'s arguments are chrome colours the session resolved - // off a theme that is not on the wire. Both want `chromeTheme().tag_bg`, - // and the frame carries it exactly: row zero IS a full-width band that - // pardes.zig fills with that colour unconditionally, which is what - // `frameChromeBg` reads. The topbar rule then wears the band's own colour, - // joining the two bands directly the way - // `config.gui_topbar_pane_border_px = 0` does — a rule whose colour we - // would have to invent is worse than no rule. const chrome = frameChromeBg(&surface); _ = renderFrame( g, gpa, null, &surface, - // No theme background either, so every cell the session left at its - // default wears this window's own ground — the same answer a terminal - // frontend gives by writing a default cell. null, chrome, chrome, - // Crt/Ripple/Glitch are core settings and the core is elsewhere; so is - // Debug, which is what the touch overlay hangs off. .{}, false, ) catch |err| blk: { @@ -2387,19 +2540,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -/// The tagline background this frame was painted with, read off the frame. An -/// attached window has no core to ask for `chromeTheme().tag_bg`, and -/// `renderFrame` wants it twice: as the chrome band under the topbar's compact -/// cells, and as the sub-cell strip `buildOverlay` extends below a bottom -/// tagline band when the window is not a whole number of cells tall. -/// -/// ROW ZERO is where it is read, and that is not a guess: the topbar is filled -/// edge to edge with `chrome.tag_bg` at `font_role = .tagline` on every frame -/// (pardes.zig `renderTopbar`), so its first tagline cell IS the colour. The -/// last row was the wrong place to look and cost a visibly dark band — a -/// session whose bottom row is pane BODY has no tagline cell there at all, so -/// the scan fell through to `bg_default` and painted the topbar's remainder -/// and every tag-cell gap near-black. fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { if (surface.rows == 0 or surface.cols == 0) return bg_default; for (surface.cells[0..surface.cols]) |cell| { @@ -2413,15 +2553,13 @@ fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { return bg_default; } -/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, -/// the rendered Surface out as text frames (same framing as the prototype). fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); @@ -2443,26 +2581,19 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("../look.zig").readFile(gpa, lp); + const bytes = try @import("../fs.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, opts, bytes); } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); - // The requested grid arrives as a resize EVENT (not init opts) so the core - // counts it; an integrated shell releases its greeting at OSC 133 B. if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see the host's spawnPane) var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2472,19 +2603,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { }; defer { lsp_workers.wait(); - queue.close(&ptys, &gens); + queue.deinit(); } var pipe_tasks: PipeTasks = .{}; defer pipe_tasks.cancelAll(io); - // no inotify here on purpose: this mode's whole contract is one frame per - // scripted input event, and a reload that arrives on its own clock would - // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. var watches: file_watch.Table = @splat(null); - // The filesystem IS served here, unlike the file watcher above: `--fs=` - // names a predictable mount point precisely so a snapshot can drive this - // mode through it. No poll thread though — see gridPollFrame. - const fs = fs_service.start(gpa, core); - defer if (fs) |f| f.deinit(); + const fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var shell: Shell = .{ .core = core, .io = io, @@ -2499,36 +2624,26 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { .watches = &watches, .fs = fs, }; + defer shell.shutdownPtys(); const host = shell.host(); - // The core owns the loop here too, but not the scripted stdin: EOF ends - // the session and nothing may be drawn after it, so this reads its own - // input and hands `pump` a pass that has already been fed. core.host = host; while (core.nextEffect()) |e| core.perform(e); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| + try spawnReader(gpa, pt, @intCast(id), gens[id], &queue); shell.threads_ok = true; pardes.lsp.setStatusSink(&queue, lspStatusSink); defer pardes.lsp.setStatusSink(null, null); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - // First frame before touching stdin, so `printf '' | pardes` still shows - // one. Its arena is released before the core's own ever allocates: both - // draw from the one stack-fallback buffer, and a live arena on top of it - // would push every later frame out to the heap. { var first: std.heap.ArenaAllocator = .init(allocs.frame); defer first.deinit(); const surface = try core.render(first.allocator()); try dumpGrid(gpa, surface); - // The grid protocol writes canonical cells; panel tracks are metadata - // for a compositor it deliberately does not run. core.acknowledgePanelPresentation(&.{}); } while (!core.quit) { - // The two halves of a pass's input, in the order the flat loop had - // them: the scripted feed, then whatever the reader threads handed - // over. `pump` has no `wait_input` to do it in — see `grid_vtable`. var in: Input = .{ .core = core }; const r = try shell.feed.pump(gpa, &in, null); if (r.eof) break; @@ -2537,11 +2652,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { try core.pump(host); if (shell.dump_err) |err| return err; } - // The last frame is outside `pump` for the same reason the first one is: - // `pump` returns before drawing a quitting pass, and this stream records - // the empty grid a closed session leaves behind. Same three host methods - // in the same order, so the idle rule and the acknowledgement stay in one - // place — only the render is out here. if (core.quit) { gridPollFrame(&shell); var last: std.heap.ArenaAllocator = .init(allocs.frame); @@ -2552,12 +2662,6 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { } } -// ===================================================================== -// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, -// plus the private synthetic finger OSC: -// ESC ] 777;finger;;;;; BEL (normalized 0..1) -// ===================================================================== - const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, @@ -2568,10 +2672,8 @@ const StdinFeed = struct { const Result = struct { eof: bool = false, n_events: usize = 0 }; - /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result { var out: Result = .{}; - // a pty stdin also carries the winsize; poll it in place of SIGWINCH var ws: posix.winsize = std.mem.zeroes(posix.winsize); if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and @@ -2600,7 +2702,6 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { - // private finger OSC first (vaxis would swallow it as unknown OSC) const prefix = "\x1b]777;finger;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { const body = f.buf[seq_start + prefix.len .. len]; @@ -2707,7 +2808,6 @@ const StdinFeed = struct { fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void { _ = f; if (g) |gp| { - // capture mode: resize the window; the frame loop resizes the core _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); _ = c.SDL_SyncWindow(gp.window); } else { @@ -2762,7 +2862,6 @@ fn setStdinRaw() !void { try posix.tcsetattr(0, .NOW, term); } -// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { if (surface.cols == 0 or surface.rows == 0) return; const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; @@ -2809,55 +2908,22 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { _ = host_io.writeFd(1, frame); } -// ===================================================================== -// SDL event dispatch -// ===================================================================== - -/// Where a translated SDL event goes, and the only thing the input path knows -/// about the session it belongs to. The local shell hands events to the -/// `Pardes` in this process; an attached window puts them on a socket, because -/// the core is in the detached one. Everything between an SDL_Event and a -/// `pardes.Event` — the keycode table, the pointer/cell mapping, the touch -/// machine, the Steam Deck mapping — is ONE translation serving both, and this -/// is what keeps it from becoming two. const Input = struct { - /// Null in an attached window, and this is also the flag the renderer- and - /// pointer-side functions test: no core means no pane rects and no theme, - /// and each of those has a documented body-grid fallback. core: ?*pardes.Pardes = null, - /// Null in a local session. Exactly one of the two is ever set. client: ?*detached_client.Client = null, - /// Attached only: the window was closed. A local session says the same - /// thing by writing `core.quit`, which the core owns and this must not - /// shadow. quit: bool = false, - /// Attached only: a send failed, which means this window has lost its - /// session. Recorded rather than returned because `dispatch` is called from - /// inside an SDL drain with no error path, and a dead link does not need - /// reporting once per event in the burst. lost: ?anyerror = null, - /// One translated event on its way to the core, wherever the core is. fn post(in: *Input, ev: pardes.Event) void { if (in.core) |core| return core.update(ev); const client = in.client orelse return; - // Nothing more goes out after the first failure: the rest of this - // burst would each fail the same way, and the loop is about to leave. if (in.lost != null) return; client.send(.{ .event = ev }) catch |err| switch (err) { - // A message this protocol cannot carry is not a link that has - // died. The one event here that can reach `wire.max_payload` is a - // paste of a 16 MiB clipboard, and dropping it beats ending a - // session over it. error.Overlong, error.NoSpace => {}, else => in.lost = err, }; } - /// The window asked to close. In a session that ends the session; in an - /// attached window it ends this frontend and nothing else — the panes, the - /// shells and the undo history are in the other process and outlive it, - /// which is the whole point of `--detach`. fn close(in: *Input) void { if (in.core) |core| core.quit = true; in.quit = true; @@ -2872,42 +2938,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_mapped = false; in.post(.pointer_leave); }, - // window resizes are picked up by the per-frame grid check c.SDL_EVENT_KEY_DOWN => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - // Ctrl+ / Ctrl-: the font size. Here rather than in keyDown - // because it is the shell's business and not the core's — the - // core has no font, and an attached window has no core at all yet - // still resizes its own text — and because this is the only side - // of the wall where `g` is in scope anyway. - // - // SIX keycodes for two keys, and every one of them is a key - // somebody actually presses: - // - `+` on a US layout IS Shift-`=`, and SDL reports the - // UNSHIFTED keycode, so Ctrl-+ arrives as SDLK_EQUALS. Binding - // only SDLK_PLUS is the usual way to ship this dead. - // - SDLK_PLUS is nonetheless real: on the German/Nordic layouts - // `+` is its own unshifted key. Same for `_` under `-`. - // - the numpad is separate. SDL_HINT_KEYCODE_OPTIONS defaults to - // "french_numbers,latin_letters" — no "hide_numpad" — so KP_+ - // stays SDLK_KP_PLUS (0x40000057) forever and never reaches - // keyDown's `sym < 128` line at all. - // - // Nothing is taken away from anyone by claiming these. forwardKey - // encodes Ctrl only for a-z, A-Z, `@` and `[`..`_`, and both `=` - // (0x3d) and `-` (0x2d) fall below that last range, so a pane in - // tty mode already sent the pty NO bytes for either — including - // Ctrl-Shift-minus, which arrives here as SDLK_MINUS and reached - // the core as `-`, never as the `_` that would have been 0x1f. No - // chord in config.zig pairs ctrl with any of these codepoints - // either (`=` is Format and `_` is trim_sels, both unmodified; - // Alt-- and Alt-_ are the selection merges). And the numpad pair - // did nothing at all: keyDown drops every sym above 128. - // - // Returning here is the whole interception, with no TEXT_INPUT - // twin to also swallow: SDL only sends text when neither ctrl nor - // alt is down (SDL_x11events.c, `!(SDL_GetModState() & (CTRL|ALT))`). const step: f32 = if (!g.live_ctrl) 0 else switch (sev.key.key) { c.SDLK_EQUALS, c.SDLK_PLUS, c.SDLK_KP_PLUS => font_px_step, c.SDLK_MINUS, c.SDLK_UNDERSCORE, c.SDLK_KP_MINUS => -font_px_step, @@ -2915,14 +2948,9 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; if (step != 0) { const want = std.math.clamp(g.px + step, font_px_min, font_px_max); - // at either end the key is inert rather than a re-raster of - // the size already on screen if (want != g.px) { g.px = want; refitFont(g, in.core); - // Attached, the new grid reaches the session as the - // ordinary window-geometry check on the next pass, and - // there is no local Font state to observe either. if (in.core) |core| observeGuiFont(g, core); } return; @@ -2939,13 +2967,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; - // ONE event is not one codepoint. An IME commit arrives whole — - // the entire phrase the candidate window was holding — and so does - // anything composed (dead keys, `Ctrl-Shift-u`, a compose-key - // sequence that resolves to more than one scalar). Decoding only - // text[0] dropped the rest on the floor, silently. Validate the - // whole string first so a truncated or malformed sequence costs - // nothing rather than half a phrase already forwarded. const view = std.unicode.Utf8View.init(text) catch return; var it = view.iterator(); var at: usize = 0; @@ -2966,8 +2987,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, b.x, b.y) orelse { g.pointer_mapped = false; - // A release outside the visible CRT tube still ends a drag at - // its last real cell; a press on black margin is inert. if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -2985,20 +3004,15 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .kind = if (b.down) .press else .release, .col = mc.col, .row = mc.row, - // asked of SDL directly rather than read off g.live_ctrl: - // that one is bookkeeping from KEY events, and a ctrl-click - // with no key pressed since startup would miss it .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; - // pad cursor continues from wherever the pointer last was g.pad_x = m.x; g.pad_y = m.y; g.pointer_present = true; - // drag = motion with a button held (selection extension keys off it) const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) .left else if ((m.state & c.SDL_BUTTON_MMASK) != 0) @@ -3036,20 +3050,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (w.y != 0 and std.math.isFinite(w.y)) { if (in.core) |core| { - // Preserve SDL's floating-point distance. Input events drained - // in this loop naturally form one render batch; stepScroll - // applies their exact sum and tells the core only about whole - // row boundaries. One accumulator belongs to one pane, so a - // wheel event over another pane first retires the old offset. const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - // There is one fractional overlay, not one per pane. Retire - // the old one at its already boundary-rounded core state; - // carrying its lag into `hit` would move the wrong pane. resetScroll(g); }; if (hit) |id| { @@ -3058,9 +3064,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { else false; if (pdf_target) { - // PDF placements live in physical document space, so - // preserve SDL's raw magnitude directly instead of - // quantizing through synthetic wheel buttons/rows. resetScroll(g); in.post(.{ .pdf_scroll = .{ .pane = @intCast(id), @@ -3074,13 +3077,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } } else { - // ATTACHED: no pane rect ever reaches this window, so there - // is nothing to slide a fractional row against — the - // session owns the panes and composes what is painted here. - // Accumulate SDL's exact distance (a precision touchpad - // sends fractions of a row) in the same field `stepScroll` - // would have drained, and hand the session the whole rows, - // which is all `Event.mouse` has ever been able to say. g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row } }); @@ -3127,8 +3123,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), - // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, - // axes polled per frame in pollGamepad) ---- c.SDL_EVENT_GAMEPAD_ADDED => { if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); }, @@ -3138,8 +3132,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.gamepad = null; } }, - // deck.zig maps buttons/triggers/trackpads to pointer, clicks, - // wheel, keys and rumble; this arm just applies its actions c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP, c.SDL_EVENT_GAMEPAD_AXIS_MOTION, @@ -3147,8 +3139,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, => { - // `pad_input` and not `in`: this file's `Input` is the event sink - // above, and deck.Input is a controller reading. const pad_input: deck.Input = switch (sev.type) { c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ .idx = sev.gbutton.button, @@ -3175,14 +3165,10 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .move => |mv| { const geometry = windowGeometry(g.window); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // deck.Action.move is in physical screen pixels. Keep the - // stored/warped cursor in SDL window coordinates. const dx = mv.dx * geometry.window_w / geometry.pixel_w; const dy = mv.dy * geometry.window_h / geometry.pixel_h; g.pad_x = std.math.clamp(g.pad_x + dx, 0, geometry.window_w - 1); g.pad_y = std.math.clamp(g.pad_y + dy, 0, geometry.window_h - 1); - // the SDL cursor (plan9 arrow) rides along, so the pad - // cursor and a hardware mouse are one visible pointer c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); g.pointer_present = true; const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse { @@ -3194,8 +3180,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_cell = mc; if (old) |previous| if (mc.col == previous.col and mc.row == previous.row) continue; - // moving with a click held drags, so selections stretch - // (a firm right-pad press drags-selects like a laptop pad) const held = heldPointerButton(g); in.post(.{ .mouse = .{ .button = held orelse .none, @@ -3213,9 +3197,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.pointer_present = true; const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse { g.pointer_mapped = false; - // Mirror hardware mouse releases: black CRT margins - // are inert for presses, but cannot strand a drag whose - // button was pressed over the visible tube. if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{ .button = button, .kind = .release, @@ -3260,20 +3241,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { .cap_n => .{ .cp = 'N', .text = "N" }, .enter => .{ .cp = pardes.Key.enter }, .tab => .{ .cp = pardes.Key.tab }, - // back paddle: flip tty mode. `--tty-toggle` moves the - // ctrl chord and is the SESSION's option, so an - // attached window — which cannot know it and has no - // core to ask — sends the spelling that cannot be - // reconfigured instead: `config.tty_toggle_alt`, which - // pardes.zig honours beside the ctrl chord for exactly - // the hosts that can express it. .tty_toggle => if (in.core) |core| .{ .cp = core.opts.tty_toggle, .ctrl = true } else .{ .cp = config.tty_toggle_alt[0].cp, .shift = true }, }, }), - // a brief gentle ack for execute/look, not a buzz .rumble => if (g.gamepad) |pad| { _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); }, @@ -3283,8 +3256,6 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { } } -/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as -/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; @@ -3305,8 +3276,6 @@ fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { c.SDLK_PAGEDOWN => pardes.Key.page_down, c.SDLK_DELETE => pardes.Key.delete, else => blk: { - // letters / digits / punctuation only as a modifier shortcut; - // plain printable (incl. space) goes via TEXT_INPUT if (!(ctrl or alt or gui_mod)) break :blk 0; if (sym < 128 and sym >= ' ') break :blk @intCast(sym); break :blk 0; @@ -3321,13 +3290,6 @@ fn pixelCell(px: f32, cell: u32) u16 { return @intFromFloat(@min(idx, 10_000)); } -/// Which grid cell a physical point is in. The COLUMN rule is -/// `pardes.gridColAt` — the inverse of the compact tagline layout, and in the -/// core beside it so the two cannot be compacted independently. `core` is null -/// in an attached window, and then the pane loop inside it is skipped and the -/// body grid answers: the same fallback `taglineLayoutForCell` takes for the -/// same missing fact, which is what makes a click on an attached tagline land -/// on the glyph it was aimed at. fn gridCellAtDimensions( core: ?*const pardes.Pardes, x: f32, @@ -3385,8 +3347,6 @@ fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: return gridCellAtPixels(g, core, mapped.x, mapped.y); } -/// SDL window coords → the scene cell displayed at that physical point. -/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map. fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window)); } @@ -3399,9 +3359,6 @@ fn heldPointerButton(g: *const Gui) ?pardes.Mouse.Button { return null; } -/// Reconcile the retained physical point with the exact scene image the GPU -/// just accepted. Same-cell frames are deliberately silent: otherwise a 60 Hz -/// scene would continuously reset the core's hover debounce. fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { if (!g.pointer_present) return; const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse { @@ -3423,28 +3380,19 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void { } }); } -/// steamdeck support: poll the sticks each frame, mirroring the trackpads — -/// RIGHT stick moves the virtual cursor at ~cell granularity (emits -/// button-less motion so hover works), LEFT stick accumulates into wheel -/// ticks (both axes: vertical + horizontal) at the cursor position. fn pollGamepad(g: *Gui, in: *Input) void { const pad = g.gamepad orelse return; const geometry = windowGeometry(g.window); const deadzone: f32 = 8000; - // right stick = pointer const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry); - // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the - // mouse-move sensitivity knob — raise for a faster pointer) const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; const speed_x = speed * geometry.window_w / geometry.pixel_w; const speed_y = speed * geometry.window_h / geometry.pixel_h; const pointer_moved = @abs(ax) > deadzone or @abs(ay) > deadzone; if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed_x, 0, geometry.window_w - 1); if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed_y, 0, geometry.window_h - 1); - // only on actual stick movement — an unconditional per-frame warp would - // pin the pointer and fight any hardware mouse if (pointer_moved) { g.pointer_present = true; c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); @@ -3467,7 +3415,6 @@ fn pollGamepad(g: *Gui, in: *Input) void { } }); } } - // left stick = scroll (both axes) const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; @@ -3484,110 +3431,140 @@ fn pollGamepad(g: *Gui, in: *Input) void { } } -// ===================================================================== -// the host seam — everything the core cannot do itself: ptys, files, the -// desktop, pixels, and the one place this process is allowed to sleep. -// ===================================================================== - -/// The state the host methods below need. `gui` is null in grid test mode: -/// no SDL, so no clipboard and no pixels, and the frames go out as text. const Shell = struct { - /// Reassigned by Restore, which is why the loop pumps rather than runs: - /// a swap is only safe BETWEEN iterations. core: *pardes.Pardes, gui: ?*Gui = null, io: std.Io, gpa: std.mem.Allocator, - /// acme's control filesystem for this session, or null when `--fs` was not - /// given (or its mount failed, or this is the headless grid harness, which - /// serves nothing). Owned by `run`. - fs: ?*fuse.Fs = null, + fs: ?*ninep_io.Listener = null, lsp_allocator: std.mem.Allocator, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const host_io.Shell.PromptFiles, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, - /// worker threads exist. The pre-loop drain forks before any of them do: - /// forkpty from a multithreaded process can wedge the child before exec. threads_ok: bool = false, - /// PARDES_TEST: input is stdin escape sequences, not SDL events test_mode: bool = false, feed: StdinFeed = .{}, - /// what the last present actually put on screen, and the frame it drew: - /// post_present may only acknowledge a frame the user has seen. presented: bool = false, surface: ?*pardes.Surface = null, - /// `pump` spends no animation time; this is where the display clock does. animation_clock: AnimationClock = .{}, - /// Whether this pass observed any input. Only the grid harness reads it: - /// its contract is one frame per scripted input event, so a pass that saw - /// nothing writes nothing. saw_event: bool = false, - /// Grid mode only: a failed write to the frame stream. Kept rather than - /// swallowed because a `present` cannot fail and the harness must. dump_err: ?anyerror = null, + retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + + fn reap(s: *Shell) void { + const now = shellClock(); + for (&s.retired_shells) |*child| reapShell(&child.pid, &child.kill_at, now); + for (s.ptys) |*slot| if (slot.*) |*pt| if (pt.fd < 0) { + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) slot.* = null; + }; + } + + fn closePty(s: *Shell, pane: u8) void { + const pt = if (s.ptys[pane]) |*pt| pt else return; + if (pt.fd < 0) return; + s.queue.cancelReader(pane, s.gens[pane]); + if (pt.reader) |thread| { + _ = host_io.writeFd(pt.stop[1], "x"); + thread.join(); + pt.reader = null; + for (pt.stop) |fd| _ = libc.close(fd); + pt.stop = .{ -1, -1 }; + } + _ = libc.close(pt.fd); + pt.fd = -1; + const now = shellClock(); + reapShell(&pt.pid, &pt.kill_at, now); + if (pt.pid == 0) { + s.ptys[pane] = null; + return; + } + _ = libc.kill(pt.pid, libc.SIG.HUP); + pt.kill_at = now + 100; + for (&s.retired_shells) |*child| if (child.pid == 0) { + child.* = .{ .pid = pt.pid, .kill_at = pt.kill_at }; + s.ptys[pane] = null; + return; + }; + } + + fn stopPtys(s: *Shell) void { + for (0..s.ptys.len) |pane| s.closePty(@intCast(pane)); + for (s.gens) |*gen| gen.* +%= 1; + s.reap(); + } + + fn shutdownPtys(s: *Shell) void { + s.stopPtys(); + for (s.retired_shells) |child| if (child.pid > 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + }; + for (s.ptys) |slot| if (slot) |pt| if (pt.pid > 0) { + _ = libc.kill(pt.pid, libc.SIG.KILL); + }; + for (&s.retired_shells) |*child| if (child.pid > 0) { + while (libc.waitpid(child.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + child.* = .{}; + }; + for (s.ptys) |*slot| if (slot.*) |pt| { + while (libc.waitpid(pt.pid, null, 0) < 0 and libc.errno(-1) == .INTR) {} + slot.* = null; + }; + } + + fn reconcilePtys(s: *Shell) void { + s.reap(); + for (s.ptys, 0..) |slot, pane| if (slot) |pt| { + if (pt.fd < 0) continue; + const current = s.core.panes[pane]; + if (current == null or current.?.serial != pt.serial or !current.?.isTerminal()) + s.closePty(@intCast(pane)); + }; + } fn host(s: *Shell) pardes.Host { return .{ .ctx = s, .vtable = if (s.gui == null) &grid_vtable else &vtable }; } const vtable: pardes.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, - .push_poll_frame = pollFrame, - .push_spawn = spawnPane, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lsp, - .pull_pipe = pipe, - .push_fs_reply = fsReply, + .wait_input = waitInput, + .present = present, + .post_present = postPresent, + .poll_frame = pollFrame, + .spawn = spawnPane, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lsp, + .pipe = pipe, }; - /// The headless grid harness. It reads its scripted stdin itself, because - /// EOF ends the session and nothing may be drawn after it — so there is no - /// `wait_input` here, and this process never sleeps in grid mode. It starts - /// neither the watcher nor the nested listener, so `drainQueue`'s - /// `files_changed` and `command` arms cannot fire behind it. - /// - /// And it has NO SDL: `SDL_Init` is never called on this path, so the two - /// desktop-clipboard methods are nulled rather than left pointing at - /// functions that cannot answer. A null `pull_read_clipboard` is not a - /// missing feature, it is the core's OWN clipboard (host.zig: "Null - /// answers immediately from the in-process clipboard instead, so a request - /// never goes unanswered") — the same one `pardes-isolate` runs on. With - /// the methods present and returning on `s.gui == null`, `SPC y` went - /// nowhere and `SPC p` was answered by nobody, so paste was dead in the - /// one mode of this shell a test can drive. const grid_vtable: pardes.Host.VTable = vt: { var v = vtable; - v.pull_wait_input = null; - v.push_poll_frame = gridPollFrame; - v.push_present = gridPresent; - v.push_post_present = gridPostPresent; - v.push_set_clipboard = null; - v.pull_read_clipboard = null; + v.wait_input = null; + v.poll_frame = gridPollFrame; + v.present = gridPresent; + v.post_present = gridPostPresent; + v.set_clipboard = null; + v.read_clipboard = null; break :vt v; }; - /// What the detached workers handed this thread since the last pass. - /// Their bytes are borrowed for exactly one `update` call each. Each - /// message is something this pass observed — the grid harness draws a - /// frame only for a pass that observed something. fn drainQueue(s: *Shell) void { + s.reconcilePtys(); var msgs = s.queue.take(); var check_files = false; for (msgs.slice()) |m| switch (m) { @@ -3598,20 +3575,22 @@ const Shell = struct { s.saw_event = true; }, .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current if (s.gens[e.pane] == e.gen) { - s.ptys[e.pane] = null; - s.core.update(.{ .eof = .{ .pane = e.pane } }); + s.closePty(e.pane); + if (e.failure) |err| { + if (s.core.panes[e.pane]) |pane| { + pane.mode = .normal; + s.core.reportError(e.pane, "terminal reader", err); + } + } else s.core.update(.{ .eof = .{ .pane = e.pane } }); } s.saw_event = true; }, .lsp => |l| { s.core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - s.lsp_allocator.free(l.rows); + if (l.rows) |rows| s.lsp_allocator.free(rows); s.saw_event = true; }, - // Server state on the transient message row of the ACTIVE pane — - // session news, same row and same stamp a completed save uses. .lsp_status => |text| { var mbuf: [256]u8 = undefined; s.core.setStatus(s.core.active, message.stamp(&mbuf, "lsp", text)); @@ -3630,17 +3609,7 @@ const Shell = struct { s.saw_event = true; s.pipe_tasks.finish(s.io, response_value.id); }, - .command => |line| { - s.core.update(.{ .command = line }); - s.gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a build, a - // `git checkout`) collapses into ONE pass below, so it cannot queue - // a reload — or an undo entry — per write. .files_changed => check_files = true, - // A wake and nothing more; the requests behind it are drained in - // pollFrame, which is where a whole batch can be answered against - // one render instead of one render per request. .fs_ready => {}, }; if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches)) @@ -3648,19 +3617,6 @@ const Shell = struct { } }; -/// The core's answer to one filesystem request, handed straight back to the -/// transport holding it. `bytes` was resolved by `pardes.fsPayload` inside -/// `perform` and is borrowed only for this call, so a megabyte body read copies -/// nothing. `.again` needs no case here: `Fs.reply` reads the status and -/// re-parks the request itself. -fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = shellOf(ctx); - if (s.fs) |f| f.reply(reply, bytes); -} - -/// The /dev/fuse poller's wake. `Queue.push` is the thread-safe door and -/// already raises the SDL user event that ends a blocking WaitEventTimeout, so -/// this is the whole callback — the same shape as watchThread's. fn wakeFs(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.push(.fs_ready); @@ -3670,16 +3626,9 @@ fn shellOf(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } -/// SDL first (blocking briefly for one event, then draining the burst), then -/// the scripted stdin feed, then the worker inbox, then the sticks. Never -/// blocks indefinitely even when the core offers to: cwd polling, the gamepad -/// and the test feed have no SDL event to wake them. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = shellOf(ctx); const core = s.core; - // The one place a local session builds the sink: everything downstream of - // here — `dispatch`, the scripted feed, the sticks — is the same code an - // attached window runs with `client` set instead. var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); @@ -3690,9 +3639,6 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { } } if (s.test_mode) { - // A dead scripted feed ends the session HERE, before the inbox, the - // sticks and the frame: the pre-pump loop broke at this line, and a - // capture written after EOF is a frame no script asked for. const r = s.feed.pump(s.gpa, &in, s.gui) catch { core.quit = true; return; @@ -3706,35 +3652,19 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { if (s.gui) |g| pollGamepad(g, &in); } -/// Per-frame host bookkeeping with no event of its own, in the order the flat -/// loop had it: the tagline face, a font the core asked for, live cwds, the -/// grid following the window, and the wheel batch — applied LAST before the -/// render, while the previous frame is still the one on screen. fn pollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); + s.reconcilePtys(); const core = s.core; - // acme's filesystem: one batch per frame, answered before anything else in - // the pass, so an edit a script just made through `body` is in the surface - // this frame composes. Ahead of the `s.gui orelse return` below because it - // has nothing to do with pixels. Hitting the cap means no ack reached the - // poll thread, so nothing else will wake us — re-arm the loop ourselves; - // `Queue.push` is lossy for this variant, which is correct, because a queue - // too full to take a wake is already holding one. - if (s.fs) |f| if (fs_service.drain(f.transport(), core).pending) s.queue.push(.fs_ready); + if (s.fs) |f| if (f.tick(core).pending) s.queue.push(.fs_ready); const g = s.gui orelse return; - // TaglineSize is pure renderer state: update the smaller face and its - // visual band immediately, without changing the body metrics or grid. syncTaglineFont(g, core); - // Font builtin: the core resolved a name to a path and asked for it — it - // cannot load a font itself, having no rasterizer, no atlas and no window. if (core.takeFontRequest()) |path| blk: { - const bytes = look.readFile(s.gpa, path) catch { + const bytes = filesystem.readFile(s.gpa, path) catch { core.rejectFont(); break :blk; }; const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { - // FreeType turned it down. Keep wearing the one that works: a font - // pardes cannot rasterize is a blank window with no way back out. log.err("ui_font_new failed: {s}", .{path}); s.gpa.free(bytes); core.rejectFont(); @@ -3749,8 +3679,6 @@ fn pollFrame(ctx: ?*anyopaque) void { acknowledgeGuiFont(g, core); } pollCwds(core, s.ptys); - // Off g.cell_w/h, not the startup metrics: a font change moves them, and - // this is the line that would go on dividing by the old cell. const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(g); stepScroll(g, core, s.gpa); @@ -3760,8 +3688,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); const core = s.core; const g = s.gui orelse return; - // renderFrame consumes the frame the core just built; nothing here writes - // to it, and post_present needs the same one to acknowledge. const frame = @constCast(surface); s.surface = frame; const scene_requested = core.settings.scene_effects.crt or @@ -3791,16 +3717,11 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { } else if (scene_requested and s.presented) g.scene_failures = 0; } -/// A tick is spent only on a frame that was actually PRESENTED: a failed -/// renderFrame must not advance samples nobody saw. fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; if (!s.presented) return; const frame = s.surface orelse return; - // Ripple/glitch move source cells under a stationary physical pointer. - // Re-feed only when that accepted scene maps to a new cell, using the same - // core mouse path a real motion event uses. refreshPresentedPointer(g, s.core); finishPresentedAnimationFrame( &s.animation_clock, @@ -3810,26 +3731,13 @@ fn postPresent(ctx: ?*anyopaque) void { ); } -/// The grid harness's own three seams. It has no window, no pointer and no -/// compositor, so what is left of a frame is the cwds a tagline draws, one -/// animation step, and the text of the grid itself. fn gridPollFrame(ctx: ?*anyopaque) void { const s = shellOf(ctx); - // The filesystem, drained on the pass rather than woken by a thread: this - // mode's contract is one frame per scripted event, and a poller posting on - // its own clock would put frames in the stream nothing asked for. fuse.zig - // supports exactly this — skip `wakeThread` and drain from the frame poll — - // and here it is not a degradation but the point. A request that changed - // something IS an event, so the pass renders: that is what lets a snapshot - // `wait` for text a script wrote through the mount. + s.reconcilePtys(); if (s.fs) |f| { - if (fs_service.drain(f.transport(), s.core).count != 0) s.saw_event = true; + if (f.tick(s.core).count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); - // The harness polls stdin at the same 16 ms cadence as native SDL, so a - // pass IS a frame interval and the tick is due here rather than behind a - // display clock. Advancing lets `stable` wait for exact endpoint colors; - // once inactive it resumes the event-only frame contract. if (s.core.animationActive()) { s.core.update(.tick); s.saw_event = true; @@ -3838,8 +3746,6 @@ fn gridPollFrame(ctx: ?*anyopaque) void { fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const s = shellOf(ctx); - // Idle pass: nothing changed, so no frame. The stream is one frame per - // scripted input event and a repeat of the last grid would be read as one. s.presented = s.saw_event; s.saw_event = false; if (!s.presented) return; @@ -3852,80 +3758,58 @@ fn gridPresent(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn gridPostPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); if (!s.presented) return; - // The grid protocol writes canonical cells; panel tracks are metadata for - // a compositor it deliberately does not run. s.core.acknowledgePanelPresentation(&.{}); } fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = shellOf(ctx); - // the core reuses pane ids and there is no close effect: a deleted pane's - // shell lives in its slot until a respawn lands here. Kill it; its - // detached reader wakes on child death and the gen-guarded eof closes the - // old fd (not here — the reader still reads it). - if (s.ptys[pane]) |old| { - _ = libc.kill(old.pid, libc.SIG.KILL); - s.ptys[pane] = null; - } + s.reap(); + s.closePty(pane); + if (s.ptys[pane] != null) return s.core.reportError(pane, "shell", error.WorkersBusy); s.gens[pane] +%= 1; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - // The machine-local half is host_io.zig's, not this file's: the same fork - // the tty shell and the detached daemon do, including the CLOEXEC on the - // master that this copy used to be missing (a master a later shell inherits - // is never closed, so a deleted pane's shell never hangs up). - const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); - const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid }; + const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); + const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid, .serial = s.core.panes[pane].?.serial }; s.ptys[pane] = pt; - // report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing about it is remembered - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); - if (s.threads_ok) spawnReader(s.gpa, pt, pane, s.gens[pane], s.queue); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + if (s.threads_ok) spawnReader(s.gpa, &s.ptys[pane].?, pane, s.gens[pane], s.queue) catch |err| { + s.closePty(pane); + s.core.reportError(pane, "terminal reader", err); + }; } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| _ = host_io.writeFd(pt.fd, bytes); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) { + _ = host_io.writeFd(pt.fd, bytes); + }; } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { + if (pt.fd < 0) return; const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } } -/// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, which -/// is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = shellOf(ctx); - if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.fd, sig); + if (s.ptys[pane]) |pt| if (pt.fd >= 0) host_io.signalTty(pt.pid, pt.fd, sig); } -/// Is a program (vim, a pager, an agent) holding this pane's tty instead of -/// the shell we forked? Asked by the core only where it is about to type a -/// command line, which is why the /proc walk behind it is not in pollCwds: -/// nothing draws this answer, and an Exec is a rare frame. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = shellOf(ctx); const pt = s.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.fd); + if (pt.fd < 0) return false; + return host_io.ttyTaken(pt.pid, pt.fd); } fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = shellOf(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // our own write is about to come back as a watch event: restamp from the - // bytes we just put there so it reads as "no change". Only for the pane's - // OWN file — a `Put` elsewhere is a change like any other. if (s.core.panes[pane]) |pane_state| if (pane_state.file) |f| { if (std.mem.eql(u8, f.path, path)) if (s.watches[pane]) |*w| if (w.serial == pane_state.serial) switch (w.generation) { @@ -3933,8 +3817,6 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside it: - // a save that did not happen must not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -3943,14 +3825,13 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = shellOf(ctx); - _ = path; // file_watch resolves it (and a PDF's) from the pane itself - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, s.watches, pane, on, mode)) s.queue.push(.files_changed); } @@ -3970,7 +3851,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = shellOf(ctx); const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -3979,21 +3860,12 @@ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } -/// Put `text` on THIS display's clipboard. The one place that copy happens: -/// SDL wants a sentinel-terminated string and a run of core cells is not one. -/// Shared, because the in-process host and an attached window answering a -/// `set_clipboard` off the wire are the same desktop action. fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void { const z = gpa.dupeZ(u8, text) catch return; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); } -/// THIS display's clipboard, or null when it holds nothing. SDL3 hands over an -/// OWNED copy that is the caller's to `SDL_free`, and reports "no text" as an -/// EMPTY string rather than null — so the length check is what actually -/// rejects a miss. Shared with the attached loop for the `putClipboard` -/// reason, turned round. fn takeClipboard() ?[:0]u8 { const raw = c.SDL_GetClipboardText() orelse return null; const text = std.mem.span(raw); @@ -4004,38 +3876,22 @@ fn takeClipboard() ?[:0]u8 { return text; } -/// Both of these are only ever reached through `Shell.vtable`, which -/// `Shell.host` installs only when there IS a window: the headless grid -/// harness nulls them and keeps the core's own clipboard. So neither needs a -/// `s.gui == null` guard, and neither may have one — a method that returns -/// without answering is exactly what left `SPC p` unanswered in grid mode. fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { putClipboard(shellOf(ctx).gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { - // SDL answers synchronously, so the paste the core is waiting on lands - // inside this same drain — nothing to remember, no reply path to plumb. const text = takeClipboard() orelse return; defer c.SDL_free(text.ptr); shellOf(ctx).core.update(.{ .paste = text }); } test "the headless grid host round-trips a yank back as a paste" { - // The GUI shell's testable mode, driven through the SAME host the grid - // harness installs — `Shell.host()` picks `grid_vtable` off `gui == null`, - // so this is the real seam and not a hand-built one. Before the two - // clipboard methods were nulled, `SPC y` reached a function that returned - // on `gui == null` and `SPC p` was answered by nobody: the content below - // never changed, in the one mode of this file a test can run. const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer core.deinit(); core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - // Everything a Shell needs that this path never touches, at its zero - // value; `io` alone is undefined, because a clipboard is not IO the - // std.Io interface knows about and no arm reached here reads it. var ptys: [pardes.MAX_PANES]?Pty = @splat(null); var gens: [pardes.MAX_PANES]u32 = @splat(0); var lsp_workers: LspWorkers = .{}; @@ -4045,10 +3901,10 @@ test "the headless grid host round-trips a yank back as a paste" { .lsp_workers = &lsp_workers, .sdl_wake = false, }; + defer queue.deinit(); var pipe_tasks: PipeTasks = .{}; var watches: file_watch.Table = @splat(null); - shell_bin.adoptSystemPath(); - var prompt_rcs = shell_bin.PromptRcs.init(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var shell: Shell = .{ .core = core, @@ -4067,8 +3923,6 @@ test "the headless grid host round-trips a yank back as a paste" { core.host = shell.host(); try std.testing.expect(core.host.vtable == &Shell.grid_vtable); - // `SPC y`: the selection to the system clipboard. Headless, "the system" - // is the core's in-process one. const pane = core.panes[0].?; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); @@ -4076,8 +3930,6 @@ test "the headless grid host round-trips a yank back as a paste" { const yanked = core.yank orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); - // ...and `SPC p` gets it back, as an ordinary paste event, inside the - // drain. A host that cannot answer leaves the file exactly as it was. const before = pane.file.?.content.len; core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'p' } }); @@ -4092,55 +3944,57 @@ fn openLink(ctx: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser } -fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = shellOf(ctx); - if (s.threads_ok) spawnLsp(s.core, s.queue, req); + if (s.threads_ok) return spawnLsp(s.core, s.queue, req); + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = shellOf(ctx); - if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + if (s.threads_ok) return spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id); + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); } -/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame -/// because a tagline draws it. Whether a pane's tty still belongs to the prompt -/// pardes forked is deliberately NOT polled with it — see `ttyTaken`. fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + if (pt.fd < 0) continue; + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } -// ===================================================================== -// fractional scroll: whole rows for the core, sub-row offsets for the picture -// ===================================================================== +fn shellClock() i64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(i64, ts.sec) * std.time.ms_per_s + @divTrunc(ts.nsec, std.time.ns_per_ms); +} + +fn reapShell(pid: *libc.pid_t, kill_at: *i64, now: i64) void { + if (pid.* <= 0) return; + const result = libc.waitpid(pid.*, null, libc.W.NOHANG); + if (result > 0 or (result < 0 and libc.errno(result) == .CHILD)) { + pid.* = 0; + kill_at.* = 0; + } else if (kill_at.* != 0 and now >= kill_at.*) { + _ = libc.kill(pid.*, libc.SIG.KILL); + kill_at.* = 0; + } +} -/// Add one raw SDL vertical-wheel value to this render batch. SDL calls up -/// positive; the picture coordinate below calls down positive. Non-finite -/// input, including an addition that overflows, cannot enter persistent state. fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; return if (std.math.isFinite(next)) next else pending; } -/// The old surface can supply at most one body-height of historical rows. -/// Clamp only pathological per-frame batches to that renderable range; normal -/// SDL deltas pass through unchanged. Including lag in the bound guarantees -/// applyScrollDelta cannot cross more than `body_rows` core boundaries. fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { const limit: f32 = @floatFromInt(body_rows); return std.math.clamp(lag + delta, -limit, limit) - lag; } -/// Apply an exact picture displacement and return the whole core rows it -/// crosses (positive = down) plus the retained sub-row picture/core offset. -/// -/// Crossing in the direction of travel rounds the core one row ahead of the -/// picture and leaves lag pointing back at it. Therefore the exposed strip is -/// always the one historical edge row which the previous surface still owns; -/// the renderer never needs a row from the future. fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { var l = lag + delta; var rows: i32 = 0; @@ -4187,8 +4041,6 @@ test "wheel magnitude is preserved without quantization" { try std.testing.expectEqual(@as(f32, -0.25), s.lag); try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); - // A finite but nonsensical device value is bounded before the reducer, - // avoiding an unbounded loop while keeping the largest renderable move. const bounded = boundScrollDelta(0, 3.0e38, 24); const safe = applyScrollDelta(0, bounded); try std.testing.expectEqual(@as(f32, 24), bounded); @@ -4215,10 +4067,6 @@ fn resetScroll(g: *Gui) void { g.scroll_edge_len = 0; } -/// Apply this frame's exact wheel batch, hand the core every whole row it -/// crossed, and retain the one historical row exposed by the residual offset. -/// Called immediately before core.render(), while core.surface still holds -/// what the previous frame drew. fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { @@ -4231,8 +4079,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); return; } - // the rect the last frame was painted through — what the snapshot below - // indexes. Nothing between here and render() moves it. const r = core.rects[id]; if (r.h <= pardes.BOX_H) { resetScroll(g); @@ -4251,9 +4097,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { .col = g.scroll_col, .row = g.scroll_row, } }); - // the document ran out under us (top of a file, bottom of a live - // terminal): there is no travel left to draw, so stop dead rather - // than slide the pane against a view that is not moving if (pane.scroll() == was) { resetScroll(g); return; @@ -4261,16 +4104,9 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } g.scroll_lag = st.lag; g.scroll_rect = r; - // the body origin travels with the rect: the two draw sites below have no - // core to ask, and with Tagbottom the body starts at r.y, not r.y + BOX_H const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H; g.scroll_body_y = body_y; if (st.rows != 0) { - // The offset opens a gap at the trailing edge of the travel, and what - // belongs in it is the row that just left the pane: already gone from - // the surface the core is about to paint, still in the one it painted - // last frame. k rows in, that row is the k-1'th body row from the top - // going down, the k'th from the bottom going up. const s = &core.surface; g.scroll_edge_len = 0; if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { @@ -4293,12 +4129,6 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { if (g.scroll_lag == 0) resetScroll(g); } -/// The fractional pane body, emitted a SECOND time at its sub-row offset, -/// plus the one row of history that fills the gap the offset opens. Writes -/// instances at `base` and returns how many; the caller draws them scissored -/// to the body, which is the whole of the clipping — the shell draws the grid -/// in one flat pass, so without it the overhanging rows would land on the -/// pane's own tag and on whatever is below it. fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; @@ -4311,7 +4141,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us - // the same layout, one sub-row up: emitInstance needs to know nothing var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); @@ -4326,8 +4155,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard n += 1; } } - // ...and the row that just left, one row outside the body on the side the - // travel came from. The scissor keeps all of it but the exposed strip. if (g.scroll_edge_len >= bw) { const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; @@ -4339,8 +4166,6 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard return n; } -/// That body rect in target pixels, clamped to the target — the scissor both -/// shells set around the draw above. fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { const r = g.scroll_rect; const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; @@ -4354,10 +4179,6 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; } -// ===================================================================== -// render: Surface → instanced quads → SDL GPU -// ===================================================================== - fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void { if (g.native_images.fetchRemove(key)) |removed| c.SDL_ReleaseGPUTexture(g.device, removed.value); @@ -4414,7 +4235,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { return false; } -fn placeIntersectsBox(place: SavedImagePlace, box: pardes.panel_animation.Box) bool { +fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); @@ -4550,21 +4371,15 @@ fn uploadNativeTexture( }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); - // SDL defers destruction until the submitted copy is done; the staging - // allocation is never needed again, so do not retain a second full image - // beside the texture for the life of the pane. try g.native_images.put(gpa, key, texture); } -/// Upload new pixel generations and the small per-frame placement buffer. -/// Returns the number of image instances drawNativeImagesGpu will consume. -// EFFECT_CODE_NATIVE_PANEL_BEGIN fn appendPreparedImage( g: *Gui, gpa: std.mem.Allocator, place: SavedImagePlace, - track: ?pardes.panel_animation.Track, - clip: ?pardes.panel_animation.Box, + track: ?pardes.layout.Track, + clip: ?pardes.layout.Box, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; @@ -4676,8 +4491,6 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, active, null, false); } } else if (active.effect == .dissolve) { - // Old layer first. Its shader half disappears at the same - // per-cell threshold at which the current half appears. for (g.presented_images.items) |saved| { if (!placeIntersectsBox(saved, active.contentBox())) continue; appendPreparedImage(g, gpa, saved, active, active.contentBox(), true); @@ -4692,18 +4505,12 @@ fn prepareNativeImages( paintBatchForSerial(plan, current.serial) != batch_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { - // An identical cached placement is semantically unchanged - // and must bypass a data effect exactly like an unchanged - // cell in Surface.cell_diffs. .dissolve => if (snapshotContainsPlacement(g, current)) null else active, else => active, } else null; appendPreparedImage(g, gpa, saved, current_track, null, false); } } - // Frozen visual under an incoming lifecycle pane. Append it after - // canonical static placements so it restores the old target pixels; - // the translated opening batch is painted later over this fixed clip. if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; @@ -4731,8 +4538,6 @@ fn prepareNativeImages( idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); - // The planning pass already rejected undrawable placements, so a mismatch - // here would desynchronize batch offsets from texture bindings. std.debug.assert(idx == capacity); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -4751,8 +4556,6 @@ fn imageScissor( ) c.SDL_Rect { if (prepared.clip) |box| return panelBoxScissor(g, box, max_w, max_h); if (prepared.track) |active| { - // Vertical travel is visible only through the pane's fixed lifecycle - // box. Other effects retain their historical visual-box clipping. const box = if (active.effect == .vertical) active.contentBox() else active.visualBox(); return panelBoxScissor(g, box, max_w, max_h); } @@ -4765,7 +4568,7 @@ fn imageScissor( }; } -fn panelBoxScissor(g: *const Gui, box: pardes.panel_animation.Box, max_w: u32, max_h: u32) c.SDL_Rect { +fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect { const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w)))); const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h)))); const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w)))); @@ -4817,9 +4620,7 @@ fn drawNativeImagesGpu( }; c.SDL_SetGPUScissor(pass, &whole); } -// EFFECT_CODE_NATIVE_PANEL_END -// EFFECT_CODE_FRAME_SUBMISSION_BEGIN fn renderFrame( g: *Gui, gpa: std.mem.Allocator, @@ -4828,27 +4629,19 @@ fn renderFrame( theme_bg: ?[3]u8, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, - scene_effects: pardes.panel_animation.SceneEffect, + scene_effects: pardes.layout.SceneEffect, debug_on: bool, ) !bool { - // Per-attempt, not per-last-success: an early swapchain/capture return - // after one real target failure must not count as another failed retry. g.scene_target_failed = false; const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; defer if (!command_consumed) { - // CPU caches become authoritative as uploads are enqueued (glyphs lose - // atlas_dirty; native textures enter the map). Submit every abandoned - // buffer so those transactions remain true. Submission is valid with - // or without a swapchain texture; cancellation is not valid after one - // is acquired and would discard capture-mode uploads before it. _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; var sw: u32 = 0; var sh: u32 = 0; var target: *c.SDL_GPUTexture = undefined; if (g.capture) { - // capture: render offscreen (the window may never present), dump PPM sw = @as(u32, surface.cols) * g.cell_w; sh = @as(u32, surface.rows) * g.cell_h; if (sw == 0 or sh == 0) { @@ -4859,8 +4652,6 @@ fn renderFrame( try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; } else if (g.soft_present) { - // No swapchain to acquire: render the window-sized frame offscreen and - // blit it in softPresentFrame below. var pw: c_int = 0; var ph: c_int = 0; if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { @@ -4888,13 +4679,8 @@ fn renderFrame( const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - // All full-window effects share one offscreen scene and one composable - // shader pass. With no bits set the ordinary render remains direct. var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch; if (scene_on) ensureSceneTexture(g, sw, sh) catch { - // The optional postprocess target does not own canonical rendering. - // Use this already-acquired command/swapchain directly for the frame; - // the loop bounds retries and clears the public bits after three. g.scene_target_failed = true; scene_on = false; }; @@ -4902,11 +4688,6 @@ fn renderFrame( const layout = fixedCellLayout(g); var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors/debug overlays do not carry pane ownership. Hide those for the - // short interval in which panel geometry differs from logical geometry; - // otherwise a cursor could remain pinned at the final cell, or paint over - // a later opening pane, while its own pane moves underneath it. The - // topbar/pane rule is anchored window chrome and remains present. var overlay_count = buildOverlay( g, core, @@ -4922,8 +4703,6 @@ fn renderFrame( if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { - // Planning wrote offsets before buffer growth/map could fail. Never - // let those counts index the previous frame's smaller/stale buffer. g.prepared_images.clearRetainingCapacity(); for (paint_plan.batches[0..paint_plan.len]) |*batch| { batch.image_start = 0; @@ -4940,11 +4719,6 @@ fn renderFrame( }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; - // Premultiplied, because that is what both a wl_surface and an X11 ARGB - // visual are composited as, and the glyph pass writes premultiplied for - // the same reason. A see-through ground is therefore all four channels - // zero and not `page.rgb` at alpha zero — the leftover colour would tint - // every glyph edge that blends against it. color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ .r = @as(f32, @floatFromInt(page.rgb[0])) / 255.0, .g = @as(f32, @floatFromInt(page.rgb[1])) / 255.0, @@ -4978,9 +4752,6 @@ fn renderFrame( ) catch return error.GpuCapacity; } } - // Vertical opening retains the frozen grid below the incoming pane; - // closing is a presentation-only copy above the new canonical grid. Only - // those pane boxes are duplicated, so surviving panes never get tracks. for (paint_plan.batches[1..paint_plan.len]) |*batch| { const track = batch.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; @@ -5008,14 +4779,9 @@ fn renderFrame( cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; } if (cell_total != 0) { - // Reserve one further grid for the fractional-scroll duplicate. Its - // path suppresses itself for an animated pane, but another static pane - // may still be scrolling while a closing tombstone is visible. const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; try ensureVbuf(g, capacity); - // ponytail: full re-upload every frame; the prototype's dirty-range - // diffing (cell_keys + coalesced ranges) is skipped for now. const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { command_consumed = true; _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -5110,8 +4876,6 @@ fn renderFrame( shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - // emitInstance may have rasterized new glyphs into the staging atlas; - // upload after vertex generation so this frame has what it references if (g.atlas_dirty) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); @@ -5121,10 +4885,6 @@ fn renderFrame( c.SDL_EndGPUCopyPass(copy); } - // Paint one complete panel before the next: its opaque cells followed by - // its native attachments. A phase-wide image tail would let an earlier - // pane's PDF/image cover a later pane's cells when their moving boxes - // overlap. PaintPlan is static, then moving slots, then opening slots. const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { @@ -5148,8 +4908,6 @@ fn renderFrame( if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); } if (has_shifted) { - // The fractional duplicate is static content and remains - // below the static pane's native attachments. const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); const binding = c.SDL_GPUBufferBinding{ @@ -5205,9 +4963,6 @@ fn renderFrame( c.SDL_EndGPURenderPass(crt_pass); } if (g.capture) { - // captureFrame consumes `cmd` on every success and error path: its - // allocation failures submit directly, and the ordinary path submits - // while acquiring the readback fence. command_consumed = true; try captureFrame(g, gpa, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5215,7 +4970,6 @@ fn renderFrame( return true; } if (g.soft_present) { - // Same contract as captureFrame: the readback submits `cmd` itself. command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; @@ -5230,21 +4984,15 @@ fn renderFrame( } return submitted; } -// EFFECT_CODE_FRAME_SUBMISSION_END -// EFFECT_CODE_CELL_INSTANCE_BEGIN const ResolvedCell = struct { slot: Slot, fg: [3]u8, bg: [3]u8, role: pardes.FontRole, - /// `bg` is the see-through ground rather than a colour: paint the glyph - /// and leave the rest of the cell to the compositor. clear_bg: bool = false, }; -// Both moved to the core so the AppKit shell can call the SAME rule over the C -// ABI instead of keeping a second copy of it — see pardes.taglineBandOffset. const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; @@ -5281,14 +5029,8 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" { fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { var fg = fg_default; var bg = page.rgb; - // Only an UNREVERSED default background is the ground; every branch below - // that names a real colour clears this, and the reverse at the end clears - // it because a reverse puts the TEXT colour there. var clear_bg = page.clear; var reverse = is_cursor; - // An invisible cell over a clear ground has nothing left to draw: `fg = bg` - // hides a glyph by painting it in the background, and a background that is - // not painted at all would let the ink through as a coloured silhouette. var blank = false; if (!cell.default) { const st = cell.style; @@ -5332,35 +5074,10 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs }; } -/// What the CORE said this cell's face is. fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole { return if (cell.default) .body else cell.style.font_role; } -/// ...and the face it is actually DRAWN in, which differs in exactly one case -/// and that case is the whole of what an attached window renders differently. -/// -/// A compact tagline band is anchored at its pane's LEFT EDGE — that is what -/// `compactTaglineLayout`'s `origin_col` is — and a pane's left edge is a pane -/// RECT, which this wire does not carry (it carries cells, not the layout that -/// placed them). Anchoring per cell instead is not a near-miss, it is a picket -/// fence: `x_off = col * (body_w - tag_w)` puts every cell back on BODY pitch -/// while the quad stays `tag_w` wide, so the chrome band shows through between -/// every pair of cells and the text tracks visibly loose. Widening the quad -/// does not close it either — `emitInstance` samples exactly `tagline_width` -/// atlas texels for a tagline cell, so a wider quad stretches the glyph. -/// -/// So a pane tag row with no core to ask goes on the BODY grid, face and all: -/// one quad per cell at body pitch and body size, tiling exactly and tracking -/// exactly. The visible difference from a local window is that those rows wear -/// the body face rather than the 82% one, and that is the price of the pane -/// rects not being on the wire. It is also the grid `gridCellAtDimensions` -/// already hit-tests an attached tag row against, so a click still lands on the -/// glyph it was aimed at. -/// -/// ROW ZERO is exempt, and that exemption is why the topbar was never striped: -/// its origin is not a pane rect but column zero, always, so its compact band -/// is right with or without a core. fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole { const role = cellFontRole(cell); if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role; @@ -5371,10 +5088,6 @@ fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1; } -/// A tagline cell has two quads. The first preserves the pane-wide chrome -/// band on the body grid; the second draws the real cell on the smaller text -/// grid. Emitting them together in increasing column order is sufficient: -/// the compact cell never reaches the next body's cell origin. fn emitSurfaceCell( g: *Gui, core: ?*const pardes.Pardes, @@ -5385,7 +5098,7 @@ fn emitSurfaceCell( body_layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, + track: ?pardes.layout.Track, cell: *const pardes.Cell, tagline_base: *const pardes.Cell, old_layer: bool, @@ -5414,25 +5127,15 @@ fn emitInstance( layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.panel_animation.Track, - /// The face this quad draws in, decided once per cell by `drawnFontRole` - /// rather than re-derived here: an attached window demotes a pane tag row - /// to the body face, and the quad geometry, the atlas slot and the uv span - /// all have to agree about that in one place. + track: ?pardes.layout.Track, role: pardes.FontRole, cell: *const pardes.Cell, - /// Old and new data layers carry their own quad geometry. The shader - /// discards exactly one at every reveal state, so a body/tagline role - /// change retains the correct band height on both sides of the diff. old_layer: bool, is_cursor: bool, - /// the ground this frame: what a default background resolves to, and - /// whether that is a colour at all page: Ground, ) void { const resolved = resolveCell(g, cell, role, is_cursor, page); - // Cell pixel rect (top-left origin) → NDC (y up). const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const visual_h: f32 = if (resolved.role == .tagline) @floatFromInt(g.tagline_height) @@ -5491,7 +5194,6 @@ fn emitInstance( if (old_layer) instances[idx].effect |= old_layer_bit; if (resolved.clear_bg) instances[idx].effect |= clear_bg_bit; } -// EFFECT_CODE_CELL_INSTANCE_END fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); @@ -5519,8 +5221,6 @@ test "insert cursor overlays without replacing the character beneath it" { .h = 20, }); try std.testing.expectEqual(@as(usize, 6), builder.len); - // A tagline caret uses the same centered visual band as its glyph and - // background, while its logical row remains the body-sized grid row. try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); } @@ -5530,7 +5230,6 @@ fn palColor(idx: u8) [3]u8 { return .{ p.r, p.g, p.b }; } -// first codepoint of a UTF-8 grapheme; space on failure/empty fn firstCp(s: []const u8) u32 { if (s.len == 0) return ' '; const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; @@ -5538,24 +5237,6 @@ fn firstCp(s: []const u8) u32 { return std.unicode.utf8Decode(s[0..n]) catch ' '; } -/// Re-measure the cell, throw the glyph atlas away, and re-fit the grid to the -/// window. THE path for any change to what a cell LOOKS like: point g.font at -/// a different face (the Font builtin, above) or write a different g.px (the -/// Ctrl+/Ctrl- in dispatch) and call this — those are one line each, and -/// everything that has to follow from them is here. -/// -/// The atlas is the part that must not be skipped, and the reason the whole -/// thing is a function rather than three lines at a call site. It is keyed by -/// codepoint and font role — two raster sizes sharing a pen of cell_w×cell_h -/// slots — so after a change every slot in it holds the wrong picture at the -/// wrong metrics, and every key already in the map would keep being -/// drawn from that slot forever, because ensureGlyph's first line is a cache -/// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put -/// it back to exactly what init built, and ensureGlyph refills it as the next -/// frame draws. The zeroing is not tidiness: the upload is the WHOLE texture, -/// the new cell size is a different grid over the same 2048², and a leftover -/// bitmap no slot points at any more would still be sampled by whatever new -/// slot overlaps it. fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { g.scale = c.ui_font_scale_for_height(g.font, g.px); var cw: c_int = 10; @@ -5573,38 +5254,17 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { resetGlyphAtlas(g); - // ...and the grid: the same window is a different number of cells now. The - // shells re-derive this every frame anyway, so this is only the frame the - // change happens on — but it is the frame the surface is about to be - // rendered for, and a stale screen_w here is a row of cells drawn off the - // right edge of the window. An attached window has no core to tell: its - // loop compares `windowCells` against the last geometry it sent and puts a - // resize on the wire from there. if (core) |p| { const geom = windowCells(g); _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h); } - // ...and a fractional scroll is measured in the OLD grid: scroll_rect - // is a rect of the pane the last frame drew, and scroll_edge is a saved row - // of exactly that rect's body WIDTH. The resize above moves both under it, - // and emitScrollRows only checks that the old rect still FITS inside the new - // surface — which it does whenever the font got smaller — so the next frame - // would paint last frame's strip over cells that are no longer the same - // text. Retire the offset instead; the next wheel event starts in the new - // grid. resetScroll(g); } -/// Rewind the shared atlas without touching body metrics. TaglineSize uses -/// this path: body glyphs are lazily reinserted at the same scale, tagline -/// glyphs at their new scale and band height. Repacking everything avoids an -/// ever-growing graveyard of old tagline slots when a config file experiments -/// with several sizes in one session. fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); @memset(g.atlas_stage, 0); - // slot (0,0) is the space glyph, exactly as init lays it out _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; @@ -5676,9 +5336,6 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; const next_vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - // Allocate the pair before retiring either old half. A failed transfer - // allocation must not leave a new vertex buffer paired with null (or a - // stale vbuf_cells value that makes the next call accept that pair). if (g.vbuf) |buffer| c.SDL_ReleaseGPUBuffer(g.device, buffer); if (g.vxfer) |transfer| c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); g.vbuf = next_vbuf; @@ -5813,8 +5470,6 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } -// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex -// buffers at all (positions from gl_VertexIndex) fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); @@ -5845,10 +5500,6 @@ fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShader return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } -// ===================================================================== -// PARDES_TEST frame capture: download the render target, write latest.ppm -// ===================================================================== - fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); @@ -5885,13 +5536,6 @@ fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { g.capture_tex_h = height; } -// ===================================================================== -// Software present: readback + SDL_Renderer blit, for compositors that -// cannot back a Vulkan swapchain (no linux-dmabuf; p9wl, remote stacks) -// ===================================================================== - -/// A colour-target format the device supports and writeCapturePpm/softPresentFrame -/// can both interpret. BGRA first because it is the usual swapchain layout. fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { const candidates = [_]c.SDL_GPUTextureFormat{ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5908,7 +5552,6 @@ fn softTargetFormat(device: *c.SDL_GPUDevice) c.SDL_GPUTextureFormat { return c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; } -/// SDL pixel format matching the byte order of a 32-bit GPU format. fn softPixelFormat(format: c.SDL_GPUTextureFormat) ?c.SDL_PixelFormat { return switch (format) { c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, @@ -5937,10 +5580,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { @intCast(height), ) orelse return error.GpuCreate; _ = c.SDL_SetTextureScaleMode(next, c.SDL_SCALEMODE_NEAREST); - // The readback IS the frame, alpha included and already premultiplied. - // SDL's default for an alpha format is BLENDMODE_BLEND, which would blend - // it a second time against the cleared window and darken every glyph edge - // over a see-through ground. _ = c.SDL_SetTextureBlendMode(next, c.SDL_BLENDMODE_NONE); g.soft_texture = next; g.soft_tex_w = width; @@ -5948,8 +5587,6 @@ fn ensureSoftTexture(g: *Gui, width: u32, height: u32) !*c.SDL_Texture { return next; } -/// Download the finished frame and blit it with SDL_Renderer. Consumes `cmd` -/// on every path, exactly like captureFrame. fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); @@ -5985,9 +5622,6 @@ fn softPresentFrame(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTex const texture = try ensureSoftTexture(g, sw, sh); const renderer = g.soft_renderer orelse return error.GpuCreate; if (!c.SDL_UpdateTexture(texture, null, mapped, @intCast(sw * bpp))) return error.GpuMap; - // Clear to nothing rather than to opaque black: on a transparent window - // this is the pixel the compositor keeps wherever the frame does not - // cover, and BLENDMODE_NONE below writes the frame over it verbatim. _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); _ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, if (g.transparent) 0 else 255); _ = c.SDL_RenderClear(renderer); @@ -6068,11 +5702,6 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -// ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a click-action -// flash HUD, alpha-blended over the grid only while the Debug builtin is on. -// ===================================================================== - fn addCursorBar( builder: *OverlayBuilder, x: u16, @@ -6126,9 +5755,6 @@ fn buildOverlay( .a = 1.0, }); } - // The core grid contains only complete cells. Extend a bottommost - // Tagbottom band through the swapchain remainder so an arbitrary window - // height cannot reintroduce a page-colored strip below the final row. const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; if (grid_bottom < win_h and bottomTaglinePresent(surface)) { const rgb = tagline_rgb; @@ -6395,12 +6021,6 @@ fn miniGlyph(ch: u8) [5]u8 { }; } -// ===================================================================== -// shared plumbing (same shapes as tty.zig) -// ===================================================================== - -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; @@ -6412,8 +6032,6 @@ fn effCp(key: vaxis.Key) u21 { return key.shifted_codepoint orelse key.codepoint; } -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, diff --git a/src/host.zig b/src/host.zig deleted file mode 100644 index 2c6bd208..00000000 --- a/src/host.zig +++ /dev/null @@ -1,345 +0,0 @@ -//! THE HOST SEAM: everything the core cannot do itself, as one struct of -//! OPTIONAL function pointers — `std.mem.Allocator`/`std.Io` shape, and the -//! generalization of two vtables this codebase already grew on its own -//! (`pardes.TtyQuery`, and the macOS shell's `Runtime`). -//! -//! Every method is optional, and a null method is not an error: the core -//! substitutes a default backed by ordinary data structures in this process -//! (`Fallback` below). So a host implements only what it actually has, and the -//! core cannot tell the difference — a `Save` lands in a real file under the -//! tty host and in `Fallback.files` under a host that never wrote a filesystem -//! method, and every path above that behaves identically. -//! -//! Two consequences worth having on purpose: -//! * The zero-method host IS the test harness. A `Host{}` is a complete, -//! deterministic, in-process pardes with a virtual filesystem, a virtual -//! clipboard and silent ptys. -//! * `Fallback` lives on the Pardes instance, not here, so N cores driven by -//! one fan-out host each keep their own state and can run in parallel. -//! -//! WHAT IS NOT HERE, and why: whether a capability EXISTS in this build stays -//! comptime and stays next to the code it shapes (`pardes.platform`, -//! `pardes.pdf_enabled`, `builtins.capabilities`, `PdfSlot`/`HapticSlot`). -//! A vtable cannot make a field zero-sized or a builtin absent from an enum. -//! The rule is: comptime decides what a BUILD has, this vtable decides who -//! SERVES it at runtime. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const source_manifest = @import("source_manifest.zig"); - -pub const LspRequest = struct { - id: u32, - kind: pardes.lsp.Kind, - pane: u8, - offset: u32, - arg: []const u8, -}; - -pub const Host = struct { - ctx: ?*anyopaque = null, - vtable: *const VTable = &.{}, - - /// One optional method per thing a host can do. Adding a method here is - /// additive for every existing host: they keep it null and get the default. - /// - /// EVERY name says how a fan-out must route it, and the compiler enforces - /// that it does (see Fanout.isPull): - /// `push_` every wrapped host gets it, and it returns nothing — a push - /// with an answer would have N answers and no way to pick one. - /// `pull_` exactly ONE host serves it, because there is one of whatever - /// comes back: one value, one sleep that ends, one `Event.paste` - /// for one Ctrl-V, one `lsp_resp` per request id. - pub const VTable = struct { - // ---- the loop's own three seams ---- - /// Block until there is input or `timeout_ms` elapses, translating - /// whatever arrives into `Pardes.update`/`postEvent` calls. This is the - /// ONLY place the process is allowed to sleep: the core never spins. - /// A pull because one host does the sleeping — fanned out, the second - /// host would not be serviced until the first happened to wake. - pull_wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, - push_present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, - /// After the frame is on screen (panel-presentation acknowledgement, - /// pointer refresh); split from `push_present` because it must observe - /// a frame the user has actually seen. - push_post_present: ?*const fn (ctx: ?*anyopaque) void = null, - /// Per-frame host bookkeeping with no event of its own: cwd polling, a - /// capability handshake landing, gamepad state. - push_poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, - - // ---- this frontend's own membership ---- - /// `Detach` — leave the session, which carries on for everybody else. - /// Only a DETACHED core's host implements it, and the null case is the - /// point rather than an oversight: a local tty or SDL shell has no - /// session to leave, so the core reports that on the pane's row (see - /// `perform`) instead of quietly quitting something. Argumentless like - /// the two frame pushes above, because the host serving it already - /// knows whose keystroke arrived — it is the one that delivered it. - push_detach: ?*const fn (ctx: ?*anyopaque) void = null, - - // ---- pseudo-terminals ---- - push_spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, - push_pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, - push_pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, - /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s - /// `sig INT`. A PUSH because there is no answer to have: `kill(2)` - /// either reaches a process that is already gone or reaches one whose - /// disposition the sender cannot see, and a script that wants to know - /// whether the program died reads the pane. NULL means this host owns - /// no pane shells and therefore has no child to signal — the browser - /// and the board, where the same null already makes `push_spawn` and - /// `push_pty_write` silent — and the effect is dropped exactly as a - /// write to a pane with no pty is. - push_pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, - /// Is this pane's terminal still the prompt the host forked, or has a - /// program (vim, a pager, an agent) taken its tty? An effect cannot - /// answer it — the `execute` that asks must choose a destination inside - /// its own update, and effects drain after. A pushed fact would mean - /// every host probing every pane's processes every frame to answer a - /// question asked when a human middle-clicks a word. So the host leaves - /// a way to be asked and the core asks where it decides. The answer - /// must not re-enter the core. - pull_tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, - - // ---- the board's own pads ---- - /// Flip one GPIO and report the level it held and the level it now holds. False means the - /// host would not do it: a pin number outside the part, or no pads at all. - /// - /// A pull, because there is one answer. The HOST answers it rather than the core reaching - /// for the registers itself - which `Peek` and `Poke` do two functions away - because - /// driving a pad correctly is not one register. It is the IO MUX function select, the GPIO - /// matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed - /// by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's - /// own headers on the die; a second copy in here would be a second copy nobody tests. - pull_gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null, - - // ---- the filesystem ---- - /// `pane` travels with the bytes only so a host that posts a "saved" - /// message row can name the right pane; the core already resolved the - /// path and the content, so save_file and save_text both land here. - /// - /// A HOST THAT COULD NOT WRITE MUST CALL `Pardes.saveFailed`, and the - /// reason it is a call rather than a return value is the rule twenty - /// lines below: a `push_` reaches every host in a fan-out, so there is - /// no single answer to give back. The core marks the pane saved - /// optimistically around this call and `saveFailed` takes it back, so a - /// write that could not happen — a read-only file, a directory removed - /// under the pane, a full disk — leaves the ` *` in the tag where it - /// was. Until that existed the pane came clean on a save that never - /// happened, and `Del` makes no dirty check: the edits were one click - /// from gone with the screen saying they were safe. - push_write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, - /// The session dump. Separate because the host also chooses WHERE it - /// goes (dump.outPath is libc-bound; the freestanding core cannot). - push_write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, - push_watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void = null, - push_watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, - push_dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, - - // ---- the desktop ---- - push_set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, - /// Ask; the answer arrives later as an ordinary `Event.paste`, which is - /// why this returns nothing and is still a pull: two hosts answering - /// would paste the clipboard twice. Null answers immediately from the - /// in-process clipboard instead, so a request never goes unanswered. - pull_read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, - push_open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, - - // ---- work that must leave the loop ---- - /// Both answer exactly once, keyed by the id they carry, so both are - /// pulls: a second host's reply would arrive for a request already - /// completed and the core would apply it to whatever holds that id now. - pull_lsp: ?*const fn (ctx: ?*anyopaque, req: LspRequest) void = null, - pull_pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, - /// Hand one filesystem answer back to whoever asked for it (a FUSE - /// `write(2)` to /dev/fuse). `bytes` is the payload the core resolved - /// for this reply and is borrowed for the length of this call — it may - /// point straight into a pane's text, so a host that needs it later - /// copies it. A push and not a pull: the answer is already computed, - /// and a second host serving the same mount is not a thing that - /// happens (the transport that asked is the one holding the request). - push_fs_reply: ?*const fn (ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void = null, - }; -}; - -/// Where a host with no `write_dump` puts a session dump. Named here so the -/// core writes it and reports it as one path. -pub const fallback_dump_path = "pardes.dump.zon"; - -/// The in-process implementations behind every null method: a virtual -/// filesystem, a virtual clipboard, and a record of what was asked of ptys and -/// the desktop. Ordinary data structures, one set per Pardes instance. -/// -/// The filesystem is not empty. It is pardes's own source, embedded — see -/// source_manifest.zig — with `files` holding only what this session WROTE, so -/// a Save shadows the built-in copy and reading it back returns the edit. That -/// is what makes a host with no file methods a usable pardes rather than one -/// staring at an empty buffer. -/// -/// Only `files` grows, and it grows by REPLACING a path's content, so no -/// session accumulates. A pane whose child does not exist is SILENT: its bytes -/// are dropped rather than transcribed, because nothing reads a transcript back -/// and a browser session would then carry every keystroke forever. -pub const Fallback = struct { - gpa: std.mem.Allocator, - files: std.StringHashMapUnmanaged([]u8) = .empty, - clipboard: std.ArrayListUnmanaged(u8) = .empty, - /// Last link a host with no browser was asked to open. - link: std.ArrayListUnmanaged(u8) = .empty, - spawned: [pardes.MAX_PANES]bool = @splat(false), - watched: [pardes.MAX_PANES]bool = @splat(false), - - pub fn deinit(f: *Fallback) void { - var it = f.files.iterator(); - while (it.next()) |e| { - f.gpa.free(e.key_ptr.*); - f.gpa.free(e.value_ptr.*); - } - f.files.deinit(f.gpa); - f.clipboard.deinit(f.gpa); - f.link.deinit(f.gpa); - } - - /// True when the bytes are in the map. The core turns a false into the same - /// `saveFailed` a real host reports, so a virtual filesystem that could not - /// allocate does not leave a pane looking saved either. - pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) bool { - const copy = f.gpa.dupe(u8, bytes) catch return false; - if (f.files.getEntry(path)) |e| { - f.gpa.free(e.value_ptr.*); - e.value_ptr.* = copy; - return true; - } - const key = f.gpa.dupe(u8, path) catch { - f.gpa.free(copy); - return false; - }; - f.files.put(f.gpa, key, copy) catch { - f.gpa.free(key); - f.gpa.free(copy); - return false; - }; - return true; - } - - /// What this path holds now: the session's own write, else the embedded - /// source. Borrowed — the bytes live in the map or in the binary. - pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { - if (f.files.get(path)) |written| return written; - return source_manifest.find(path); - } - - pub fn setClipboard(f: *Fallback, text: []const u8) void { - f.clipboard.clearRetainingCapacity(); - f.clipboard.appendSlice(f.gpa, text) catch {}; - } - - pub fn setLink(f: *Fallback, url: []const u8) void { - f.link.clearRetainingCapacity(); - f.link.appendSlice(f.gpa, url) catch {}; - } -}; - -/// Fan out one core's host calls to several real hosts at once — the debugging -/// arrangement: every input reaches every host, and each host answers into its -/// own state. -/// -/// It advertises a method only when some wrapped host actually implements it, -/// so wrapping does NOT mask the core's per-method fallback: fan out two hosts -/// that never opened a link and the link still lands in `Fallback`. -pub const Fanout = struct { - hosts: []const Host, - vt: Host.VTable = .{}, - - pub fn init(hosts: []const Host) Fanout { - var f: Fanout = .{ .hosts = hosts }; - inline for (@typeInfo(Host.VTable).@"struct".fields) |field| { - for (hosts) |h| if (@field(h.vtable, field.name) != null) { - @field(f.vt, field.name) = @field(all, field.name); - break; - }; - } - return f; - } - - pub fn host(f: *const Fanout) Host { - return .{ .ctx = @ptrCast(@constCast(f)), .vtable = &f.vt }; - } - - fn self(ctx: ?*anyopaque) *const Fanout { - return @ptrCast(@alignCast(ctx.?)); - } - - /// A wrapper for every method, whether or not this fan-out advertises it. - /// Synthesized, so adding a method to `Host.VTable` needs no code here. - const all: Host.VTable = blk: { - var t: Host.VTable = .{}; - for (@typeInfo(Host.VTable).@"struct".fields) |field| { - @field(t, field.name) = fan(field.name); - } - break :blk t; - }; - - fn Method(comptime name: []const u8) std.builtin.Type.Fn { - const ptr = @typeInfo(@FieldType(Host.VTable, name)).optional.child; - return @typeInfo(@typeInfo(ptr).pointer.child).@"fn"; - } - - /// How to route a method, read off its own name. A method that is neither - /// is a COMPILE ERROR rather than a silent push, because the failure of a - /// forgotten pull is invisible in every unit test and obvious only to the - /// user: one Ctrl-V pasting twice. - fn isPull(comptime name: []const u8) bool { - if (std.mem.startsWith(u8, name, "pull_")) return true; - if (std.mem.startsWith(u8, name, "push_")) { - if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ - name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); - return false; - } - @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ - "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); - } - - /// The walk, written once: `args` is everything after `ctx`. - fn dispatch(comptime name: []const u8, ctx: ?*anyopaque, args: anytype) Method(name).return_type.? { - for (self(ctx).hosts) |h| if (@field(h.vtable, name)) |fp| { - const answer = @call(.auto, fp, .{h.ctx} ++ args); - if (comptime isPull(name)) return answer; - }; - // `init` installs a wrapper only when some host has the method, so a - // pull always found one; a zero is the honest answer if that changes. - const R = Method(name).return_type.?; - if (comptime R != void) return std.mem.zeroes(R); - } - - /// One wrapper, built from the method's own signature: the parameter list - /// is the only part that cannot be derived, so there is one shape per - /// arity rather than one per method. - fn fan(comptime name: []const u8) @FieldType(Host.VTable, name) { - const m = Method(name); - const R = m.return_type.?; - const P = m.params; - return switch (P.len) { - 1 => struct { - fn w(c: ?*anyopaque) R { - return dispatch(name, c, .{}); - } - }.w, - 2 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?) R { - return dispatch(name, c, .{a}); - } - }.w, - 3 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?) R { - return dispatch(name, c, .{ a, b }); - } - }.w, - 4 => struct { - fn w(c: ?*anyopaque, a: P[1].type.?, b: P[2].type.?, d: P[3].type.?) R { - return dispatch(name, c, .{ a, b, d }); - } - }.w, - else => @compileError("Fanout has no wrapper shape for " ++ name ++ "'s arity"), - }; - } -}; diff --git a/src/host_io.zig b/src/host_io.zig index 6ffc890e..000d1a88 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1,200 +1,939 @@ -//! THE MACHINE-LOCAL HALF OF A HOST: fork a pane's shell, put bytes on a disk. -//! -//! `host.zig` is the seam — the struct of function pointers the core asks -//! through. This file is the part of the answer that is the same on every host -//! that has an operating system under it, and it is now the ONLY copy of it: -//! tty.zig, detached/server.zig, gui/gui.zig and macos.zig all fork and write -//! through here. They did not always. Each of the four grew its own `forkShell` -//! and its own `writeFd`, and what those four copies were for is best said by -//! what they had in common: ALL FOUR were missing FD_CLOEXEC on the pty master, -//! so in every shell pardes has ever shipped a program in one pane could read -//! and write another pane's terminal, and closing a master did not reliably hang -//! its shell up. One line below fixes that for all four at once (see `forkShell`) -//! — which is a better argument for this file existing than "it is shared" is. -//! -//! Why the daemon and not the frontend does this work: a unix socket means the -//! core and its frontends are on the SAME machine, so there is no question of -//! whose disk or whose process table is meant. Given that, the pane shells -//! belong to the long-lived process, because the whole promise of a detached -//! session is that it outlives the frontend attached to it — a shell forked by -//! a frontend dies with that frontend, and then the session has a pane with no -//! shell in it. The frontend keeps exactly what needs the human's screen: the -//! grid, the keyboard, the clipboard and a link to open. -//! -//! So `forkShell` takes the core it is forking on behalf of and nothing about -//! terminals: no vaxis, no `Loop`, no reader thread. Who drains the master fd -//! is the caller's business, and the callers answer differently on purpose. The -//! tty, gui and macOS shells hand it to a worker that posts into their event -//! loop; the daemon adds it to the one `poll(2)` it already runs over its -//! clients, and makes its own copy non-blocking in order to. That last is why -//! `Child.file.flags` is left saying what it says: the flag describes the -//! descriptor `forkpty` handed back, for the three callers that stream it, and -//! the one that polls it keeps only the handle. +const builtin = @import("builtin"); const std = @import("std"); const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); -const shell_bin = @import("shell_bin.zig"); -const fs_service = @import("fs_service.zig"); -const fuse = @import("fuse.zig"); - -/// `setCloexec` and nothing else. Imported rather than copied a fourth time — -/// fuse.zig and nested.zig each grew a private two-line version of it — because -/// the descriptor this file has to protect is the one every OTHER file in the -/// tree already protects, and one predicate is how the reasoning stays in one -/// place. nested.zig is a leaf (std, builtin, libc), so this costs no -/// dependency worth the name. -const nested = @import("nested.zig"); +const ninep_io = @import("9p_io.zig"); +const filesystem = @import("fs.zig"); + +pub const Host = struct { + ctx: ?*anyopaque = null, + vtable: *const VTable = &.{}, + + pub const VTable = struct { + wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, + present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, + post_present: ?*const fn (ctx: ?*anyopaque) void = null, + poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, + detach: ?*const fn (ctx: ?*anyopaque) void = null, + spawn: ?*const fn (ctx: ?*anyopaque, pane: u8, cwd: []const u8) void = null, + pty_write: ?*const fn (ctx: ?*anyopaque, pane: u8, bytes: []const u8) void = null, + pty_resize: ?*const fn (ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void = null, + pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null, + tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null, + gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null, + write_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void = null, + write_dump: ?*const fn (ctx: ?*anyopaque, bytes: []const u8) void = null, + watch_file: ?*const fn (ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void = null, + watch_theme: ?*const fn (ctx: ?*anyopaque, generation: u32, on: bool) void = null, + dump_themes: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null, + set_clipboard: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null, + read_clipboard: ?*const fn (ctx: ?*anyopaque) void = null, + open_link: ?*const fn (ctx: ?*anyopaque, url: []const u8) void = null, + lsp: ?*const fn (ctx: ?*anyopaque, req: Lsp.Request) void = null, + pipe: ?*const fn (ctx: ?*anyopaque, id: u32) void = null, + }; +}; + +pub const Fallback = struct { + pub const dump_path = "pardes.dump.zon"; + + gpa: std.mem.Allocator, + files: std.StringHashMapUnmanaged([]u8) = .empty, + clipboard: std.ArrayListUnmanaged(u8) = .empty, + link: std.ArrayListUnmanaged(u8) = .empty, + spawned: [pardes.MAX_PANES]bool = @splat(false), + watched: [pardes.MAX_PANES]bool = @splat(false), + + pub fn deinit(f: *Fallback) void { + var it = f.files.iterator(); + while (it.next()) |entry| { + f.gpa.free(entry.key_ptr.*); + f.gpa.free(entry.value_ptr.*); + } + f.files.deinit(f.gpa); + f.clipboard.deinit(f.gpa); + f.link.deinit(f.gpa); + } + + pub fn writeFile(f: *Fallback, path: []const u8, bytes: []const u8) bool { + const copy = f.gpa.dupe(u8, bytes) catch return false; + if (f.files.getEntry(path)) |entry| { + f.gpa.free(entry.value_ptr.*); + entry.value_ptr.* = copy; + return true; + } + const key = f.gpa.dupe(u8, path) catch { + f.gpa.free(copy); + return false; + }; + f.files.put(f.gpa, key, copy) catch { + f.gpa.free(key); + f.gpa.free(copy); + return false; + }; + return true; + } + + pub fn get(f: *const Fallback, path: []const u8) ?[]const u8 { + if (f.files.get(path)) |written| return written; + return filesystem.sourceBytes(path); + } + + pub fn setClipboard(f: *Fallback, text: []const u8) void { + f.clipboard.clearRetainingCapacity(); + f.clipboard.appendSlice(f.gpa, text) catch {}; + } + + pub fn setLink(f: *Fallback, url: []const u8) void { + f.link.clearRetainingCapacity(); + f.link.appendSlice(f.gpa, url) catch {}; + } +}; + +pub const Lsp = struct { + pub const Request = struct { + id: u32, + kind: pardes.lsp.Kind, + pane: u8, + offset: u32, + arg: []const u8, + }; + + pub const Task = struct { + id: u32, + future: std.Io.Future(anyerror!void), + }; + + /// One language query, owned by the worker that runs it. + pub const Job = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + pub fn free(job: *Job, gpa: std.mem.Allocator) void { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.free(job.root); + gpa.destroy(job); + } + }; + + // Copy before starting a worker; the editor may replace any source slice afterward. + pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: Request) !*Job { + if (req.pane >= core.panes.len) return error.NoPane; + const pane = core.panes[req.pane] orelse return error.NoPane; + const file = pane.file; + const job = try gpa.create(Job); + errdefer gpa.destroy(job); + const declared_path = if (file) |f| f.path else ""; + const path = try gpa.dupe(u8, filesystem.localPath(declared_path) orelse declared_path); + errdefer gpa.free(path); + const source = try gpa.dupeZ(u8, if (file) |f| f.content else ""); + errdefer gpa.free(source); + const arg = try gpa.dupe(u8, req.arg); + errdefer gpa.free(arg); + const declared_root = if (file) |f| std.fs.path.dirname(f.path) orelse "/" else pane.cwdSlice(); + const root = try gpa.dupe(u8, filesystem.localPath(declared_root) orelse declared_root); + job.* = .{ + .id = req.id, + .kind = req.kind, + .offset = req.offset, + .path = path, + .source = source, + .arg = arg, + .root = root, + }; + return job; + } + + // Null is failure; the receiver frees non-null rows with the job's allocator. + pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: ?[]u8) void; + + pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + pardes.lsp.query(gpa, arena.allocator(), .{ + .kind = job.kind, + .path = job.path, + .source = job.source, + .offset = job.offset, + .arg = job.arg, + .root = job.root, + }, &out.writer) catch { + deliver(ctx, job.id, null); + return; + }; + const rows = out.toOwnedSlice() catch { + deliver(ctx, job.id, null); + return; + }; + deliver(ctx, job.id, rows); + } + + test "a snapshot owns every byte the backend will read" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + var needle: [6]u8 = "needle".*; + const job = try snapshot(gpa, core, .{ + .id = 7, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = &needle, + }); + defer job.free(gpa); + + try std.testing.expectEqual(@as(u32, 7), job.id); + try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); + try std.testing.expectEqualStrings("needle", job.arg); + try std.testing.expect(job.arg.ptr != &needle); + try std.testing.expectEqualStrings("", job.path); + try std.testing.expectEqual(@as(usize, 0), job.source.len); + try std.testing.expectEqual(@as(u8, 0), job.source[0]); + const pane = core.panes[core.active].?; + try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); + if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); + } + + test "LSP snapshot frees every partially copied field on allocation failure" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + _ = try core.setTestFile("const copied = true;\n"); + const Snapshot = struct { + fn check(gpa: std.mem.Allocator, p: *const pardes.Pardes) !void { + const job = try snapshot(gpa, p, .{ + .id = 7, + .kind = .hover, + .pane = @intCast(p.active), + .offset = 6, + .arg = "query", + }); + defer job.free(gpa); + try std.testing.expectEqualStrings("const copied = true;\n", job.source); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Snapshot.check, .{core}); + } + + test "LSP snapshots translate explicit OS paths once and retain virtual names" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("const value = true;\n"); + for ([_]struct { path: []const u8, native: []const u8 }{ + .{ .path = "/n/os/project/file.zig", .native = "/project/file.zig" }, + .{ .path = "/n/os/n/os/project/file.zig", .native = "/n/os/project/file.zig" }, + .{ .path = "/virtual/src/file.zig", .native = "/virtual/src/file.zig" }, + }) |case| { + const replacement = try gpa.dupe(u8, case.path); + gpa.free(pane.file.?.path); + pane.file.?.path = replacement; + const job = try snapshot(gpa, core, .{ .id = 1, .kind = .hover, .pane = @intCast(core.active), .offset = 0, .arg = "" }); + defer job.free(gpa); + try std.testing.expectEqualStrings(case.native, job.path); + try std.testing.expectEqualStrings(std.fs.path.dirname(case.native).?, job.root); + try std.testing.expectEqualStrings(case.path, pane.file.?.path); + } + } + + test "a pane that is gone yields no job rather than a null deref" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + // The effect is drained after the core has moved on, so the pane it names + // may already have been deleted. Every shell open-coded this check. + const empty = for (core.panes, 0..) |slot, id| { + if (slot == null) break @as(u8, @intCast(id)); + } else return error.NoEmptyPane; + try std.testing.expectError(error.NoPane, snapshot(gpa, core, .{ + .id = 1, + .kind = .definition, + .pane = empty, + .offset = 0, + .arg = "", + })); + } + + test "work consumes the job and hands its rows to the sink" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + + const Sink = struct { + var seen_id: u32 = 0; + var seen_rows: ?[]u8 = null; + fn take(_: ?*anyopaque, id: u32, rows: ?[]u8) void { + seen_id = id; + seen_rows = rows; + } + }; + Sink.seen_id = 0; + Sink.seen_rows = null; + + const job = try snapshot(gpa, core, .{ + .id = 42, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }); + work(gpa, job, null, Sink.take); + + // `status` is the one kind that answers with no file and no cursor, which + // is what makes it assertable here without a language server on the box. + try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); + const rows = Sink.seen_rows orelse return error.SinkNeverCalled; + defer gpa.free(rows); + } + + test "LSP edit query failure leaves text and undo untouched before a successful retry" { + if (!pardes.lsp.supports.contains(.format)) return; + const Sink = struct { + core: *pardes.Pardes, + gpa: std.mem.Allocator, + calls: usize = 0, + failed: bool = false, + + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + defer if (rows) |text| self.gpa.free(text); + self.calls += 1; + self.failed = rows == null; + self.core.update(.{ .lsp_resp = .{ .id = id, .rows = rows } }); + } + }; + const gpa = std.testing.allocator; + for ([_]pardes.lsp.Kind{ .format, .rename }) |kind| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("const value=1;\n"); + const path = try gpa.dupe(u8, "/file.zig"); + gpa.free(pane.file.?.path); + pane.file.?.path = path; + pane.cur_col = 6; + const revision = pane.file.?.revision; + const undo_len = pane.file.?.history.undo_len; + var failing = std.testing.FailingAllocator.init(gpa, .{}); + var sink: Sink = .{ .core = core, .gpa = failing.allocator() }; + for ([_]bool{ true, false }) |fail| { + failing.fail_index = std.math.maxInt(usize); + core.lspRequest(core.active, kind, "renamed"); + const job = try snapshot(failing.allocator(), core, .{ + .id = core.lsp_wait.?.id, + .kind = kind, + .pane = @intCast(core.active), + .offset = 6, + .arg = "renamed", + }); + if (fail) failing.fail_index = failing.alloc_index; + work(failing.allocator(), job, &sink, Sink.take); + try std.testing.expectEqual(fail, sink.failed); + try std.testing.expect(core.lsp_wait == null); + if (fail) { + try std.testing.expectEqualStrings("const value=1;\n", pane.file.?.content); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqual(undo_len, pane.file.?.history.undo_len); + } else { + try std.testing.expectEqualStrings(if (kind == .format) "const value = 1;\n" else "const renamed=1;\n", pane.file.?.content); + try std.testing.expectEqual(undo_len + 1, pane.file.?.history.undo_len); + } + } + try std.testing.expectEqual(@as(usize, 2), sink.calls); + } + } + + test "LSP work delivers failure when transferring result ownership cannot allocate" { + if (!pardes.lsp.supports.contains(.status)) return; + const TransferAllocator = struct { + failed: bool = false, + fail_copy: bool = false, + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx)); + if (self.fail_copy) { + self.failed = true; + return null; + } + return std.testing.allocator.rawAlloc(len, alignment, ra); + } + fn resize(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) bool { + return std.testing.allocator.rawResize(bytes, alignment, len, ra); + } + fn remap(ctx: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, len: usize, ra: usize) ?[*]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx)); + if (len < bytes.len) { + self.fail_copy = true; + return null; + } + return std.testing.allocator.rawRemap(bytes, alignment, len, ra); + } + fn free(_: *anyopaque, bytes: []u8, alignment: std.mem.Alignment, ra: usize) void { + std.testing.allocator.rawFree(bytes, alignment, ra); + } + }; + const Sink = struct { + calls: usize = 0, + id: u32 = 0, + rows: ?[]u8 = null, + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + self.calls += 1; + self.id = id; + self.rows = rows; + } + }; + var allocator: TransferAllocator = .{}; + const gpa: std.mem.Allocator = .{ .ptr = &allocator, .vtable = &.{ + .alloc = TransferAllocator.alloc, + .resize = TransferAllocator.resize, + .remap = TransferAllocator.remap, + .free = TransferAllocator.free, + } }; + const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer core.deinit(); + const job = try snapshot(gpa, core, .{ .id = 37, .kind = .status, .pane = @intCast(core.active), .offset = 0, .arg = "" }); + var sink: Sink = .{}; + work(gpa, job, &sink, Sink.take); + if (sink.rows) |rows| gpa.free(rows); + try std.testing.expect(allocator.failed); + try std.testing.expectEqual(@as(usize, 1), sink.calls); + try std.testing.expectEqual(@as(u32, 37), sink.id); + try std.testing.expect(sink.rows == null); + } +}; + +test { + _ = Lsp; +} + +pub const Shell = struct { + const X_OK: c_int = 1; + + extern "c" fn mkstemp(template: [*:0]u8) c_int; + extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + + const path_capacity = 4096; + const max_path_files = 64; + + // Repair only the system-only PATH inherited from a macOS GUI launch. + fn adoptSystemPath() void { + if (comptime builtin.os.tag != .macos) return; + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath("/etc/paths", "/etc/paths.d", &buf, &len); + if (len == 0) return; + const system = buf[0..len]; + + const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; + if (!allEntriesWithin(current, system)) return; + if (std.mem.eql(u8, current, system)) return; + + var out: [path_capacity:0]u8 = undefined; + if (len >= out.len) return; + @memcpy(out[0..len], system); + out[len] = 0; + _ = setenv("PATH", out[0..len :0].ptr, 1); + } + + // Run in the parent before forking; children borrow the completed prompt files. + pub fn prepare() PromptFiles { + adoptSystemPath(); + if (comptime builtin.os.tag.isDarwin()) + _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + return PromptFiles.init(); + } + + fn collectSystemPath(paths_file: [:0]const u8, paths_dir: []const u8, buf: []u8, len: *usize) void { + var file_buf: [path_capacity]u8 = undefined; + if (readSmall(paths_file, &file_buf)) |body| appendLines(buf, len, body); + + const io = std.Io.Threaded.global_single_threaded.io(); + var dir = std.Io.Dir.cwd().openDir(io, paths_dir, .{ .iterate = true }) catch return; + defer dir.close(io); + + var names: [max_path_files][256]u8 = undefined; + var name_lens: [max_path_files]usize = undefined; + var count: usize = 0; + var it = dir.iterate(); + while (count < names.len) { + const entry = (it.next(io) catch break) orelse break; + if (entry.kind == .directory) continue; + if (entry.name.len == 0 or entry.name.len > names[count].len) continue; + @memcpy(names[count][0..entry.name.len], entry.name); + name_lens[count] = entry.name.len; + count += 1; + } + var order: [max_path_files]usize = undefined; + for (0..count) |i| order[i] = i; + std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); + + var path_buf: [512]u8 = undefined; + for (order[0..count]) |i| { + const name = names[i][0..name_lens[i]]; + const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ paths_dir, name }, 0) catch continue; + if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); + } + } + + const Names = struct { + names: *const [max_path_files][256]u8, + lens: *const [max_path_files]usize, + + fn lessThan(self: Names, a: usize, b: usize) bool { + return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; + } + }; + + fn appendLines(buf: []u8, len: *usize, body: []const u8) void { + var lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); + } + + fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { + if (entry.len == 0) return; + if (hasEntry(buf[0..len.*], entry)) return; + const separator: usize = if (len.* == 0) 0 else 1; + if (len.* + separator + entry.len > buf.len) return; + if (separator == 1) { + buf[len.*] = ':'; + len.* += 1; + } + @memcpy(buf[len.*..][0..entry.len], entry); + len.* += entry.len; + } + + fn hasEntry(list: []const u8, entry: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, list, ':'); + while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; + return false; + } + + fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { + var it = std.mem.tokenizeScalar(u8, candidate, ':'); + while (it.next()) |entry| if (!hasEntry(list, entry)) return false; + return true; + } + + fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); + if (fd < 0) return null; + defer _ = libc.close(fd); + var off: usize = 0; + while (off < buf.len) { + const n = libc.read(fd, buf[off..].ptr, buf.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return null; + } + if (n == 0) break; + off += @intCast(n); + } + return buf[0..off]; + } + + test "the launchd PATH is replaced and a configured one is left alone" { + var buf: [256]u8 = undefined; + var len: usize = 0; + appendEntry(&buf, &len, "/usr/bin"); + appendEntry(&buf, &len, "/bin"); + appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first + appendEntry(&buf, &len, ""); + try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); + + try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); + try std.testing.expect(allEntriesWithin("", "/usr/bin")); + try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); + try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); + } + + test "system path files are sorted and duplicate directories keep their first position" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths", .data = " /usr/bin \n/bin\n\n/usr/bin\n" }); + try tmp.dir.createDirPath(std.testing.io, "paths.d"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/20-last", .data = "/opt/local/bin\n/usr/bin\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "paths.d/10-first", .data = "/opt/homebrew/bin\n/bin\n" }); + var root_buf: [std.fs.max_path_bytes]u8 = undefined; + const root = root_buf[0..try tmp.dir.realPath(std.testing.io, &root_buf)]; + var file_buf: [std.fs.max_path_bytes]u8 = undefined; + const paths_file = try std.fmt.bufPrintSentinel(&file_buf, "{s}/paths", .{root}, 0); + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const paths_dir = try std.fmt.bufPrint(&dir_buf, "{s}/paths.d", .{root}); + var buf: [path_capacity]u8 = undefined; + var len: usize = 0; + collectSystemPath(paths_file, paths_dir, &buf, &len); + try std.testing.expectEqualStrings("/usr/bin:/bin:/opt/homebrew/bin:/opt/local/bin", buf[0..len]); + } + + const Family = enum { bash, fish, none }; + + fn family(bin: []const u8) Family { + const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); + const base = if (slash) |s| bin[s + 1 ..] else bin; + if (std.mem.startsWith(u8, base, "bash")) return .bash; + if (std.mem.startsWith(u8, base, "fish")) return .fish; + return .none; + } + + const bash_rc = + \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' + \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C\a"' DEBUG + \\ + ; + + // fish -C runs after config.fish; bash --rcfile must source .bashrc itself. + const fish_rc = + \\functions -c fish_prompt __pardes_user_prompt + \\function fish_prompt + \\ printf '\e]133;A;cl=line\a' + \\ __pardes_user_prompt + \\ printf '\e]133;B\a' + \\end + \\function __pardes_preexec --on-event fish_preexec + \\ printf '\e]133;C\a' + \\end + \\function __pardes_postexec --on-event fish_postexec + \\ printf '\e]133;D\a' + \\end + \\ + ; + + const rc_path_capacity = 64; + + // Private files live until host teardown. Lengths keep this value movable. + pub const PromptFiles = struct { + bash_path: [rc_path_capacity:0]u8 = @splat(0), + bash_len: u8 = 0, + fish_path: [rc_path_capacity:0]u8 = @splat(0), + fish_len: u8 = 0, + fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0), + fish_command_len: u8 = 0, + + pub fn init() PromptFiles { + var rcs: PromptFiles = .{}; + rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc); + rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc); + if (rcs.fishPath()) |path| { + const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch { + _ = libc.unlink(path.ptr); + rcs.fish_len = 0; + return rcs; + }; + rcs.fish_command_len = @intCast(command.len); + } + return rcs; + } + + pub fn deinit(rcs: *PromptFiles) void { + if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr); + if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr); + rcs.bash_len = 0; + rcs.fish_len = 0; + rcs.fish_command_len = 0; + } + + fn bashPath(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.bash_len == 0) return null; + return rcs.bash_path[0..rcs.bash_len :0]; + } + + fn fishPath(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.fish_len == 0) return null; + return rcs.fish_path[0..rcs.fish_len :0]; + } + + fn fishCommand(rcs: *const PromptFiles) ?[:0]const u8 { + if (rcs.fish_command_len == 0) return null; + return rcs.fish_command[0..rcs.fish_command_len :0]; + } + }; + + // Publish a path only after its private 0600 file is fully written and closed. + fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 { + const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0; + const fd = mkstemp(path.ptr); + if (fd < 0) return 0; + var off: usize = 0; + while (off < contents.len) { + const n = libc.write(fd, contents[off..].ptr, contents.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + _ = libc.close(fd); + _ = libc.unlink(path.ptr); + return 0; + } + if (n == 0) { + _ = libc.close(fd); + _ = libc.unlink(path.ptr); + return 0; + } + off += @intCast(n); + } + if (libc.close(fd) != 0) { + _ = libc.unlink(path.ptr); + return 0; + } + return @intCast(path.len); + } + + test "shell family is the basename's prefix, and anything else runs unadorned" { + try std.testing.expectEqual(Family.fish, family("fish")); + try std.testing.expectEqual(Family.fish, family("/usr/bin/fish")); + try std.testing.expectEqual(Family.fish, family("/opt/homebrew/bin/fish")); + try std.testing.expectEqual(Family.bash, family("bash")); + try std.testing.expectEqual(Family.bash, family("/bin/bash")); + try std.testing.expectEqual(Family.bash, family("/usr/bin/bash-5.2")); + try std.testing.expectEqual(Family.fish, family("/usr/local/bin/fish-3.7")); + try std.testing.expectEqual(Family.none, family("/opt/fish/bin/nu")); + try std.testing.expectEqual(Family.none, family("/usr/bin/zsh")); + try std.testing.expectEqual(Family.none, family("/bin/sh")); + try std.testing.expectEqual(Family.none, family("nu")); + try std.testing.expectEqual(Family.none, family("")); + } + + const bin_dirs = [_][]const u8{ + "/usr/bin/", + "/bin/", + "/usr/local/bin/", + "/opt/homebrew/bin/", + "/opt/local/bin/", + "/usr/sbin/", + }; + + const fallbacks = [_][]const u8{ + if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", + "/bin/sh", + }; + + pub const Spawn = struct { + path: [*:0]const u8, + argv: [4:null]?[*:0]const u8, + }; + + // Resolve in the parent. The path buffer and prompt files must survive through exec. + pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptFiles) Spawn { + const path = find(bin, buf) orelse fallback(buf); + const marks: [2]?[*:0]const u8 = switch (family(std.mem.span(path))) { + .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null }, + .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null }, + .none => .{ null, null }, + }; + return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; + } + + fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { + if (bin.len == 0 or bin.len + 1 > buf.len) return null; + if (std.mem.indexOfScalar(u8, bin, '/') != null) { + @memcpy(buf[0..bin.len], bin); + buf[bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + return if (libc.access(p, X_OK) == 0) p else null; + } + for (bin_dirs) |dir| { + if (dir.len + bin.len + 1 > buf.len) continue; + @memcpy(buf[0..dir.len], dir); + @memcpy(buf[dir.len..][0..bin.len], bin); + buf[dir.len + bin.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + return null; + } + + fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { + for (fallbacks) |f| { + @memcpy(buf[0..f.len], f); + buf[f.len] = 0; + const p: [*:0]const u8 = @ptrCast(buf); + if (libc.access(p, X_OK) == 0) return p; + } + return @ptrCast(buf); + } + + test "a path is taken at its word, a name is looked up, and both pick their own marks" { + if (builtin.os.tag == .windows) return; + var buf: [std.fs.max_path_bytes]u8 = undefined; + var prompt_rcs = PromptFiles.init(); + defer prompt_rcs.deinit(); + + const sh = resolve("/bin/sh", &buf, &prompt_rcs); + try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); + try std.testing.expect(sh.argv[1] == null); + + const bash = resolve("bash", &buf, &prompt_rcs); + try std.testing.expect(family(std.mem.span(bash.path)) == .bash); + try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); + try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?)); + + const missing = resolve("zznosuchshell", &buf, &prompt_rcs); + try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); + try std.testing.expect(libc.access(missing.path, X_OK) == 0); + + const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs); + try std.testing.expect(libc.access(gone.path, X_OK) == 0); + } + + test "prompt rc owners have private complete files and clean them up" { + if (builtin.os.tag == .windows) return; + var original = PromptFiles.init(); + var a = original; + original = .{}; + original.deinit(); + defer a.deinit(); + var b = PromptFiles.init(); + defer b.deinit(); + const a_bash = a.bashPath() orelse return error.TempCreateFailed; + const b_bash = b.bashPath() orelse return error.TempCreateFailed; + const a_fish = a.fishPath() orelse return error.TempCreateFailed; + try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash)); + const fish_command = a.fishCommand() orelse return error.MissingFishCommand; + try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]); + try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]); + for ([_][]const u8{ a_bash, b_bash, a_fish }) |path| { + const stat = try std.Io.Dir.cwd().statFile(std.testing.io, path, .{}); + try std.testing.expectEqual(std.Io.File.Kind.file, stat.kind); + try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077); + } + var fish_buf: [fish_rc.len]u8 = undefined; + try std.testing.expectEqualStrings(fish_rc, readSmall(a_fish, &fish_buf) orelse return error.ReadFailed); + + var buf: [bash_rc.len]u8 = undefined; + const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return error.OpenFailed; + defer _ = libc.close(fd); + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + len += @intCast(n); + } + try std.testing.expectEqualStrings(bash_rc, buf[0..len]); + + var removed: [rc_path_capacity:0]u8 = @splat(0); + @memcpy(removed[0..a_bash.len], a_bash); + removed[a_bash.len] = 0; + a.deinit(); + try std.testing.expect(libc.access(&removed, 0) < 0); + try std.testing.expectEqualStrings(bash_rc, readSmall(b_bash, &buf) orelse return error.ReadFailed); + } +}; extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -/// A forked pane shell: the pty master to read and write, and the pid to reap. -/// Named rather than anonymous because four files now hold one of these. pub const Child = struct { file: std.Io.File, pid: posix.pid_t, }; -/// Fork a shell onto a fresh pty for `pane`, sized `rows`x`cols`. -/// -/// `core` is optional because a host may fork before it has one, and a core -/// that is absent simply does not name its shell. pub fn forkShell( core: ?*pardes.Pardes, pane: usize, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const Shell.PromptFiles, bin: []const u8, - cwd: ?[*:0]const u8, + cwd: []const u8, rows: u16, cols: u16, - fs: ?*const fuse.Fs, -) Child { - var master: c_int = undefined; - // resolved BEFORE the fork, into this frame, which the child inherits: - // nothing between fork and exec may allocate, and a PATH search would + fs: ?*const ninep_io.Listener, +) !Child { + const native_cwd = filesystem.localPath(cwd) orelse cwd; + if (std.mem.indexOfScalar(u8, native_cwd, 0) != null) return error.InvalidPath; + var cwd_buf: [4096]u8 = undefined; + const cwd_z: ?[:0]const u8 = if (native_cwd.len == 0) null else dir: { + const path = std.fmt.bufPrintSentinel(&cwd_buf, "{s}", .{native_cwd}, 0) catch return error.NameTooLong; + const stat = try std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), path, .{}); + if (stat.kind != .directory) return error.NotDir; + break :dir path; + }; + var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs); - // ...and so is the pane's own address on the control filesystem, for a - // second reason on top of that one: acme puts `winid` in the child, which - // is safe there only because rfork(RFENVG) has just given it a private - // environment group. See fs_service.exportPaneEnv. - fs_service.exportPaneEnv(fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0); + const spawn = Shell.resolve(bin, &path_buf, prompt_rcs); + ninep_io.exportPaneEnv( + fs, + if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, + if (core) |c| !c.opts.nested else false, + ); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); + if (pid < 0) return error.ForkFailed; if (pid == 0) { - // the blocked-SIGWINCH mask survives fork AND exec — unblock it or - // bash/vim in the pane would never see resizes (sigprocmask is - // async-signal-safe) var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); - if (cwd) |c| _ = chdir(c); + if (cwd_z) |path| if (chdir(path.ptr) != 0) _exit(126); _ = execv(spawn.path, &spawn.argv); _exit(127); } - if (pid > 0) { - // CLOEXEC ON THE MASTER, and it belongs here rather than at either - // caller because `forkpty` is what opens it: /dev/ptmx is opened with no - // O_CLOEXEC and there is no flag argument to ask for one. Without this, - // every pane shell forked AFTER this one inherits this master and keeps - // it across `execv`, which is two bugs at once. - // - // The loud one: a program running in pane 3 can read pane 0's output and - // write bytes into pane 0's screen. - // - // The silent one, and the reason it compounds: closing a master is the - // only thing that hangs its shell up, and a master a later shell still - // holds open is not closed. detached/server.zig `closePty` and tty.zig - // `spawn` both depend on that hangup, so a pane delete or a respawn left - // an orphaned shell that never exits — never reaped, eventually blocked - // writing into a pty nobody reads — and each orphan pinned every earlier - // pane's master in turn. The startup drain forks pane 0 and then pane 1, - // so the arrangement existed from boot, and it existed in all four - // copies of this function before they became this one. nested.zig and - // fuse.zig say the same thing about their own descriptors ("pane shells - // are forked with forkpty and inherit everything open"); the master was - // the one descriptor in the tree that nobody had said it to. - // - // THE WINDOW THIS LEAVES, stated rather than papered over: fcntl after - // fork is not atomic, so a thread that forks and execs between these two - // syscalls inherits the master anyway. In the detached daemon there is no - // such thread — it is single-threaded by construction, which is what - // putting the pty masters in its own `poll(2)` bought. The shells with - // worker threads that can exec — tty.zig's pipe tasks above all — have a - // window two syscalls wide, and closing it means replacing `forkpty` with - // our own `posix_openpt(O_CLOEXEC)` / `grantpt` / `unlockpt` / fork / - // `setsid`, which is a different change to a different file. - nested.setCloexec(master); - if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); - } + ninep_io.setCloexec(master); + if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null); return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } -/// Truncate-or-create `path` and put `bytes` there. False on any failure, and -/// the caller reports it: a save that did not happen must not be announced as -/// one. -/// WHY it failed, and not merely that it did. A save is the one operation in -/// this program whose failure a user must not be able to miss, and until this -/// returned an error there was nothing for a host to put on the message row: -/// the bool said "no" and every caller answered it with a bare `return`. -/// `NoSpaceLeft` is the one that most needs saying — the file has already been -/// truncated by the time it happens, so a save that reports nothing has -/// destroyed the file it was asked to preserve. -pub const WriteError = error{ - PathTooLong, - PermissionDenied, - IsDirectory, - ReadOnlyFilesystem, - NoSpaceLeft, - OpenFailed, - WriteFailed, -}; +test "shell spawn rejects invalid directories before creating a child" { + const rcs: Shell.PromptFiles = .{}; + try std.testing.expectError(error.InvalidPath, forkShell(null, 0, &rcs, "/bin/sh", "/tmp\x00/ignored", 24, 80, null)); + const too_long = [_]u8{'x'} ** 4096; + try std.testing.expectError(error.NameTooLong, forkShell(null, 0, &rcs, "/bin/sh", &too_long, 24, 80, null)); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file", .data = "not a directory\n" }); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var path_buf: [4096]u8 = undefined; + const file = try std.fmt.bufPrint(&path_buf, "{s}/file", .{directory}); + try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", file, 24, 80, null)); + const explicit_file = try std.fmt.bufPrint(&path_buf, "/n/os{s}/file", .{directory}); + try std.testing.expectError(error.NotDir, forkShell(null, 0, &rcs, "/bin/sh", explicit_file, 24, 80, null)); + const missing = try std.fmt.bufPrint(&path_buf, "{s}/missing/" ++ ("child/" ** 50), .{directory}); + try std.testing.expect(missing.len > 256); + try std.testing.expectError(error.FileNotFound, forkShell(null, 0, &rcs, "/bin/sh", missing, 24, 80, null)); +} + +test "shell spawn uses an explicit OS directory longer than 256 bytes" { + if (!haveFile("/bin/sh")) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const sub_path = "nested-directory-with-more-than-forty-characters/" ** 7; + try tmp.dir.createDirPath(std.testing.io, sub_path); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPathFile(std.testing.io, sub_path, &directory_buf)]; + try std.testing.expect(directory.len > 256); + var explicit_buf: [4096]u8 = undefined; + const explicit = try std.fmt.bufPrint(&explicit_buf, "/n/os{s}", .{directory}); + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(null, 0, &rcs, "/bin/sh", explicit, 24, 80, null); + defer { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + _ = libc.close(child.file.handle); + } + var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; + try std.testing.expect(writeFd(child.file.handle, "printf '\\nPARDES-CWD:'; pwd; exit\n")); + var expected_buf: [4096]u8 = undefined; + const expected = try std.fmt.bufPrint(&expected_buf, "PARDES-CWD:{s}", .{directory}); + try std.testing.expect(sh.waitText(expected, 5_000)); +} -pub fn writeFileBytes(path: []const u8, bytes: []const u8) WriteError!void { - var pathbuf: [4096:0]u8 = undefined; - if (path.len >= pathbuf.len) return error.PathTooLong; - @memcpy(pathbuf[0..path.len], path); - pathbuf[path.len] = 0; - const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return switch (libc.errno(fd)) { - .ACCES, .PERM => error.PermissionDenied, - .ISDIR => error.IsDirectory, - .ROFS => error.ReadOnlyFilesystem, - .NOSPC, .DQUOT => error.NoSpaceLeft, - .NAMETOOLONG => error.PathTooLong, - else => error.OpenFailed, - }; - const wrote = writeFd(fd, bytes); - // The close is part of the write. NFS and every write-back filesystem - // report a deferred error here and nowhere else, so a close that fails on a - // file we believe we wrote is a file we did not write. - const closed = libc.close(fd) == 0; - if (!wrote or !closed) return error.WriteFailed; -} - -/// A whole-buffer write that finishes short writes, retries EINTR, and refuses -/// to loop on no progress. -/// -/// The zero guard is not bookkeeping: without it a `write(2)` that returns 0 for -/// a nonzero count is an infinite SPIN, because 0 is neither an error nor -/// progress and `off` never moves. macos.zig's copy carried the guard and its -/// reason all along — "a zero-byte write makes no progress; looping on it would -/// spin the main thread forever" — and the tty copy this file was extracted -/// from did not, so the extraction briefly promoted the weakest of the three to -/// being the shared one. All three are now this one: gui.zig and macos.zig were -/// migrated onto it, so the guard is no longer missing anywhere. -/// -/// A spin is strictly worse than the block it replaces, which is why this -/// matters more now that detached/server.zig reaches this file from a -/// single-threaded poll loop: a blocked `write` is one syscall a signal can -/// interrupt, and a spin is 100% of a core with the whole session behind it. -/// True when every byte went. The answer is new: this used to return `void`, so -/// a full disk and a completed write were the same event to every caller — and -/// the one caller that matters had already truncated the file. A pty write -/// ignores it, which is what `_ =` at those call sites means. pub fn writeFd(fd: c_int, data: []const u8) bool { var off: usize = 0; while (off < data.len) { @@ -208,3 +947,512 @@ pub fn writeFd(fd: c_int, data: []const u8) bool { } return true; } + +const vnode_info_path = extern struct { + vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid + path: [1024]u8, // MAXPATHLEN +}; +const proc_vnodepathinfo = extern struct { + cdir: vnode_info_path, + rdir: vnode_info_path, +}; +const PROC_PIDVNODEPATHINFO: c_int = 9; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var pbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; + const n = libc.readlink(path, buf.ptr, buf.len); + if (n <= 0 or n >= buf.len) return null; + return buf[0..@intCast(n)]; + }, + .macos, .ios, .tvos, .watchos, .visionos => { + var info: proc_vnodepathinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); + if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; + const path = std.mem.sliceTo(&info.cdir.path, 0); + if (path.len == 0 or path.len == info.cdir.path.len or path.len > buf.len) return null; + @memcpy(buf[0..path.len], path); + return buf[0..path.len]; + }, + else => return null, + } +} + +test "shell cwd rejects truncation and preserves an owned child path longer than 1024 bytes" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + if (!haveFile("/bin/sh")) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const nested = "nested-directory-with-more-than-forty-characters/" ** 24; + try tmp.dir.createDirPath(io, nested); + var path_buf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + const directory = path_buf[0..try tmp.dir.realPathFile(io, nested, &path_buf)]; + try std.testing.expect(directory.len > 1024); + const rcs: Shell.PromptFiles = .{}; + const child = try forkShell(null, 0, &rcs, "/bin/sh", directory, 24, 80, null); + defer { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + _ = libc.close(child.file.handle); + } + var sh: TestShell = .{ .master = child.file.handle, .pid = child.pid }; + try std.testing.expect(writeFd(child.file.handle, "printf '\\160ardes-cwd-ready\\n'\n")); + try std.testing.expect(sh.waitText("pardes-cwd-ready", 5_000)); + var result: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + try std.testing.expect(shellCwd(child.pid, result[0..0]) == null); + try std.testing.expect(shellCwd(child.pid, result[0..1024]) == null); + try std.testing.expect(shellCwd(child.pid, result[0..directory.len]) == null); + try std.testing.expectEqualStrings(directory, shellCwd(child.pid, result[0 .. directory.len + 1]) orelse return error.MissingCwd); + try std.testing.expectEqualStrings(directory, shellCwd(child.pid, &result) orelse return error.MissingCwd); +} + +extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; + +const occ_max_depth: u8 = 8; +const occ_max_visited: usize = 32; + +const TtyProbe = struct { + self_exe: [std.fs.max_path_bytes]u8 = undefined, + exe: [std.fs.max_path_bytes]u8 = undefined, + blob: [4096]u8 = undefined, + pending: [occ_max_visited]Node = undefined, + + const Node = struct { pid: libc.pid_t, depth: u8 }; +}; + +pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { + switch (builtin.os.tag) { + .linux => { + var probe: TtyProbe = undefined; + const fg = tcgetpgrp(master_fd); + if (fg < 0) return false; + const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; + + var saw_fg = fg == shell_pid; + var pending: usize = 0; + var visited: usize = 0; + switch (pushChildren(&probe, &pending, shell_pid, 1)) { + .pushed => {}, + .unreadable => return false, + .full => return true, + } + + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + visited += 1; + if (visited > occ_max_visited) return true; + + const pgrp = procPgrp(node.pid, &probe.blob); + if (pgrp) |g| { + if (g == fg) saw_fg = true; + } + + const exe = procExe(node.pid, &probe.exe) orelse { + if (offTty(node.pid, &probe.blob)) continue; + return true; + }; + if (!std.mem.eql(u8, exe, self_exe)) { + if (pgrp) |g| if (g == fg) return true; + continue; + } + if (node.depth >= occ_max_depth) return true; + switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { + .pushed => {}, + .unreadable => {}, + .full => return true, + } + } + return !saw_fg; + }, + else => return false, + } +} + +pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { + const sig = switch (which) { + .int => libc.SIG.INT, + .term => libc.SIG.TERM, + .hup => libc.SIG.HUP, + .quit => libc.SIG.QUIT, + .kill => libc.SIG.KILL, + }; + const fg = tcgetpgrp(master_fd); + if (fg > 0) { + _ = libc.kill(-fg, sig); + return; + } + if (shell_pid > 0) _ = libc.kill(shell_pid, sig); +} + +fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + const got = libc.read(fd, buf.ptr, buf.len); + if (got <= 0) return null; + return buf[0..@intCast(got)]; +} + +fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { + var name: [64:0]u8 = undefined; + const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; + const n = libc.readlink(link, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; +} + +fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; + return parsePgrp(readProc(path, buf) orelse return null); +} + +fn parsePgrp(stat: []const u8) ?libc.pid_t { + const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; + var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); + _ = fields.next() orelse return null; // 3: state + _ = fields.next() orelse return null; // 4: ppid + const pgrp = fields.next() orelse return null; // 5: pgrp + return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; +} + +fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; + const status = readProc(path, buf) orelse return true; + return parseZombie(status); +} + +fn parseZombie(status: []const u8) bool { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "State:")) continue; + const state = std.mem.trim(u8, line["State:".len..], " \t\r"); + return state.len > 0 and state[0] == 'Z'; + } + return false; +} + +const Pushed = enum { pushed, unreadable, full }; + +fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { + var name: [96:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ + @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), + }, 0) catch return .unreadable; + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return .unreadable; + defer _ = libc.close(fd); + const got = libc.read(fd, &probe.blob, probe.blob.len); + if (got < 0) return .unreadable; + + var kids: [occ_max_visited]libc.pid_t = undefined; + const total = parseChildren(probe.blob[0..@intCast(got)], &kids); + if (total > kids.len or pending.* + total > probe.pending.len) return .full; + for (kids[0..total]) |kid| { + probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; + pending.* += 1; + } + return .pushed; +} + +fn parseChildren(text: []const u8, out: []libc.pid_t) usize { + var total: usize = 0; + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + while (it.next()) |tok| { + if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; + const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; + if (total < out.len) out[total] = kid; + total += 1; + } + return total; +} + +test "the children blob parses to pids, and a garbage token never becomes one" { + var out: [8]libc.pid_t = undefined; + try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); + try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); + try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); + try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); + try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); + try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); + var two: [2]libc.pid_t = undefined; + try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); +} + +test "the process group comes off the last ')', not a comm-shifted stat field" { + const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ + "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; + try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); + try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); + try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); + try std.testing.expect(parsePgrp("") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); + try std.testing.expect(parsePgrp("no parens here at all") == null); +} + +test "the zombie state comes off its own status line" { + try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); + try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); + try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); + try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); + try std.testing.expect(!parseZombie("Name:\tsh\nSta")); + try std.testing.expect(!parseZombie("State:\t")); +} + +const test_shell = "/bin/bash"; +const test_prompt = "PZX> "; + +const TestShell = struct { + master: c_int, + pid: libc.pid_t, + tail: [8192]u8 = undefined, + tail_len: usize = 0, + + fn start() ?TestShell { + if (!haveFile(test_shell)) return null; + var master: c_int = undefined; + const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid < 0) return null; + if (pid == 0) { + const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; + _ = execv(test_shell, &argv); + _exit(127); + } + var sh: TestShell = .{ .master = master, .pid = pid }; + sh.send("export PS1='PZ''X> '\n"); + if (!sh.waitText(test_prompt, 10_000)) { + sh.stop(); + return null; + } + sh.forget(); + return sh; + } + + fn send(sh: *TestShell, bytes: []const u8) void { + _ = libc.write(sh.master, bytes.ptr, bytes.len); + } + + fn forget(sh: *TestShell) void { + sh.tail_len = 0; + } + + fn drain(sh: *TestShell) void { + while (true) { + var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; + if (libc.poll(&fds, 1, 0) <= 0) return; + if (fds[0].revents & libc.POLL.IN == 0) return; + var chunk: [4096]u8 = undefined; + const n = libc.read(sh.master, &chunk, chunk.len); + if (n <= 0) return; + sh.append(chunk[0..@intCast(n)]); + } + } + + fn append(sh: *TestShell, bytes: []const u8) void { + if (bytes.len >= sh.tail.len) { + @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); + sh.tail_len = sh.tail.len; + return; + } + const room = sh.tail.len - sh.tail_len; + if (bytes.len > room) { + const drop = bytes.len - room; + std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); + sh.tail_len -= drop; + } + @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); + sh.tail_len += bytes.len; + } + + fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + sh.drain(); + if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + fn taken(sh: *TestShell) bool { + sh.drain(); + return ttyTaken(sh.pid, sh.master); + } + + fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + if (sh.taken() == want) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (nowMs() < deadline) { + if (sh.taken() != want) return false; + sleepMs(5); + } + return true; + } + + fn stop(sh: *TestShell) void { + var probe: TtyProbe = undefined; + var pending: usize = 0; + var doomed: [occ_max_visited]libc.pid_t = undefined; + var n: usize = 0; + _ = pushChildren(&probe, &pending, sh.pid, 1); + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + if (n == doomed.len) break; + doomed[n] = node.pid; + n += 1; + if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); + } + _ = libc.kill(sh.pid, libc.SIG.KILL); + for (doomed[0..n]) |kid| { + _ = libc.kill(kid, libc.SIG.KILL); + _ = libc.kill(-kid, libc.SIG.KILL); + } + _ = libc.waitpid(sh.pid, null, 0); + _ = libc.close(sh.master); + } +}; + +fn haveFile(path: [*:0]const u8) bool { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return false; + _ = libc.close(fd); + return true; +} + +fn nowMs() i64 { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +fn sleepMs(ms: i64) void { + const ts = libc.timespec{ + .sec = @intCast(@divFloor(ms, 1000)), + .nsec = @intCast(@mod(ms, 1000) * 1_000_000), + }; + _ = libc.nanosleep(&ts, null); +} + +test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + try std.testing.expect(sh.holdsTaken(false, 200)); + + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + sh.forget(); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); +} + +test "a background job is not the tty's owner" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.send("sleep 30 &\n"); + try std.testing.expect(sh.waitText("[1]", 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.send("kill %1\n"); + try std.testing.expect(sh.holdsTaken(false, 300)); +} + +test "a nested interactive shell is still a prompt" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.send("bash --norc -c 'sleep 30'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + + sh.send("bash --norc -c 'sleep 30; :'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + var probe: TtyProbe = undefined; + var pending: usize = 0; + try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); + try std.testing.expectEqual(@as(usize, 1), pending); + var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; + var shell_buf: [std.fs.max_path_bytes]u8 = undefined; + try std.testing.expectEqualStrings( + procExe(sh.pid, &shell_buf).?, + procExe(probe.pending[0].pid, &wrapper_buf).?, + ); + + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "a full-screen program takes the tty until it quits" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ + .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, + .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, + }; + var ran: usize = 0; + for (cases) |c| { + if (!haveFile(c.bin)) continue; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + sh.send(c.run); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.forget(); + sh.send(c.quit); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + ran += 1; + } + if (ran == 0) return error.SkipZigTest; +} diff --git a/src/image.zig b/src/image.zig index 50e9649c..592230c8 100644 --- a/src/image.zig +++ b/src/image.zig @@ -1,7 +1,4 @@ -//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept -//! pixels render as PETSCII glyph art directly into the Surface — or, when the -//! host supports native images (and PETSCII isn't toggled), ride the Surface as -//! a pixel attachment the shell transmits/places (TTY: Kitty; SDL: GPU texture). +//! Image decoding, pixel placement, and glyph approximation. const std = @import("std"); const zstbi = @import("zstbi"); /// The 16-colour ANSI table below is the only thing this file ever wanted from @@ -10,21 +7,434 @@ const zstbi = @import("zstbi"); const terminal_panes = @import("pardes.zig").terminal_panes; const ghostty_vt = if (terminal_panes) @import("ghostty-vt") else struct {}; const pdf_enabled = @import("pardes_config").mupdf; -pub const petscii = @import("petscii.zig"); - -/// the terminal's own 16 ANSI colors — what the `terminal` palette mode -/// scores against; cells then paint as indexed colors so the real terminal -/// resolves the RGB -/// -/// These are GHOSTTY's sixteen, transcribed, because on a platform that has an -/// emulator the scoring used to read them straight out of it and a build -/// without one has to score against the identical table or render different -/// glyph art for the same photo. Note they are the base16 Tomorrow Night set, -/// NOT the xterm defaults: zig-pkg/ghostty-1.3.2-dev-5UdBCzeJ.../src/terminal/ -/// color.zig:389-405 (`Name.default`), which color.zig:8-15 copies into the -/// first sixteen entries of `color.default`. The comptime block below makes -/// the emulator prove that, so the transcription cannot rot in silence. -const ansi_default = [16][3]u8{ +// Glyph matching adapted from caioluders/petsciinator. +pub const GlyphArt = struct { + pub const commodore = [16][3]u8{ + .{ 0x00, 0x00, 0x00 }, // 0 black + .{ 0xff, 0xff, 0xff }, // 1 white + .{ 0x68, 0x37, 0x2b }, // 2 red + .{ 0x70, 0xa4, 0xb2 }, // 3 cyan + .{ 0x6f, 0x3d, 0x86 }, // 4 purple + .{ 0x58, 0x8d, 0x43 }, // 5 green + .{ 0x35, 0x28, 0x79 }, // 6 blue + .{ 0xb8, 0xc7, 0x6f }, // 7 yellow + .{ 0x6f, 0x4f, 0x25 }, // 8 orange + .{ 0x43, 0x39, 0x00 }, // 9 brown + .{ 0x9a, 0x67, 0x59 }, // 10 light red + .{ 0x44, 0x44, 0x44 }, // 11 dark grey + .{ 0x6c, 0x6c, 0x6c }, // 12 grey + .{ 0x9a, 0xd2, 0x84 }, // 13 light green + .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue + .{ 0x95, 0x95, 0x95 }, // 15 light grey + }; + + const Palette = [16][3]u8; + + pub const Cell = struct { + glyph: [4]u8 = .{ ' ', 0, 0, 0 }, + glyph_len: u3 = 1, + fg: u4 = 1, + bg: u4 = 0, + }; + + pub const Grid = struct { cells: []Cell, cols: usize, rows: usize }; + + fn dist2(a: [3]u8, b: [3]u8) u32 { + const dr = @as(i32, a[0]) - b[0]; + const dg = @as(i32, a[1]) - b[1]; + const db = @as(i32, a[2]) - b[2]; + return @intCast(dr * dr + dg * dg + db * db); + } + + fn nearest(px: [3]u8, pal: Palette) u4 { + var best: u4 = 0; + var bestd: u32 = std.math.maxInt(u32); + for (pal, 0..) |c, i| { + const d = dist2(px, c); + if (d < bestd) { + bestd = d; + best = @intCast(i); + } + } + return best; + } + + // ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- + const Glyph = struct { cp: u21, bits: u64 }; + + fn sextantCp(p: u6) u21 { + return switch (p) { + 0 => ' ', + 21 => 0x258C, // left half ▌ + 42 => 0x2590, // right half ▐ + 63 => 0x2588, // full block █ + else => blk: { + var off: u21 = @as(u21, p) - 1; + if (p > 21) off -= 1; + if (p > 42) off -= 1; + break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants + }, + }; + } + + // the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). + fn sextantBits(p: u6) u64 { + var bits: u64 = 0; + var y: usize = 0; + while (y < 8) : (y += 1) { + const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 + var x: usize = 0; + while (x < 8) : (x += 1) { + const col: usize = if (x < 4) 0 else 1; + const sub: u6 = @intCast(band * 2 + col); + if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); + } + } + return bits; + } + + // pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. + fn glyphMask(r: [8]u8) u64 { + var b: u64 = 0; + for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); + return b; + } + + // the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they + // can't express an exact 4-row half or a quarter; these fill that gap. + const block_glyphs = [_]Glyph{ + .{ .cp = 0x2580, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half + .{ .cp = 0x2584, .bits = glyphMask(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half + .{ .cp = 0x2598, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL + .{ .cp = 0x259d, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR + .{ .cp = 0x2596, .bits = glyphMask(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL + .{ .cp = 0x2597, .bits = glyphMask(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR + .{ .cp = 0x259a, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR + .{ .cp = 0x259e, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL + .{ .cp = 0x259b, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR + .{ .cp = 0x259c, .bits = glyphMask(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL + .{ .cp = 0x2599, .bits = glyphMask(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR + .{ .cp = 0x259f, .bits = glyphMask(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL + }; + + // line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. + const line_glyphs = [_]Glyph{ + .{ .cp = 0x2500, .bits = glyphMask(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ + .{ .cp = 0x2502, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ + .{ .cp = 0x253c, .bits = glyphMask(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ + .{ .cp = 0x2572, .bits = glyphMask(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ + .{ .cp = 0x2571, .bits = glyphMask(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ + .{ .cp = 0x2573, .bits = glyphMask(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ + }; + + const ascii_glyphs = [_]Glyph{ + .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! + .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " + .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # + .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ + .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % + .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & + .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' + .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( + .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) + .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * + .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + + .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , + .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - + .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . + .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / + .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 + .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 + .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 + .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 + .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 + .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 + .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 + .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 + .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 + .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 + .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : + .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; + .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < + .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = + .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > + .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? + .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ + .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A + .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B + .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C + .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D + .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E + .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F + .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G + .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H + .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I + .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J + .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K + .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L + .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M + .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N + .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O + .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P + .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q + .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R + .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S + .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T + .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U + .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V + .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W + .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X + .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y + .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z + .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ + .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ + .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] + .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ + .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ + .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` + .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a + .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b + .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c + .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d + .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e + .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f + .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g + .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h + .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i + .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j + .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k + .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l + .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m + .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n + .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o + .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p + .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q + .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r + .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s + .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t + .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u + .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v + .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w + .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x + .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y + .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z + .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { + .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | + .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } + .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ + }; + + // the block glyph table (sextants + half/quadrant blocks + line glyphs), built at + // comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. + const glyphs = blk: { + @setEvalBranchQuota(100000); + var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; + var p: usize = 0; + while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; + for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; + for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; + break :blk list; + }; + const glyphs_ascii = glyphs ++ ascii_glyphs; + + fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { + var counts = [_]u16{0} ** 16; + for (cell) |px| counts[nearest(px, pal)] += 1; + var cand: [4]u4 = undefined; + var ncand: usize = 0; + var used = [_]bool{false} ** 16; + while (ncand < 4) : (ncand += 1) { + var best: ?usize = null; + for (counts, 0..) |c, i| { + if (used[i] or c == 0) continue; + if (best == null or c > counts[best.?]) best = i; + } + if (best) |bi| { + cand[ncand] = @intCast(bi); + used[bi] = true; + } else break; + } + if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total + if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color + + var best_cost: i64 = std.math.maxInt(i64); + var best = encode(' ', cand[0], cand[0]); + var fi: usize = 0; + while (fi < ncand) : (fi += 1) { + var bi: usize = 0; + while (bi < ncand) : (bi += 1) { + if (fi == bi) continue; + const fg = cand[fi]; + const bg = cand[bi]; + var dfg: [64]u32 = undefined; + var dbg: [64]u32 = undefined; + var base: i64 = 0; + for (cell, 0..) |px, p| { + dfg[p] = dist2(px, pal[fg]); + dbg[p] = dist2(px, pal[bg]); + base += dbg[p]; + } + for (gset) |g| { + var delta: i64 = 0; + var bits = g.bits; + while (bits != 0) : (bits &= bits - 1) { + const p: usize = @ctz(bits); + delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); + } + const cost = base + delta; + if (cost < best_cost) { + best_cost = cost; + best = encode(g.cp, fg, bg); + } + } + } + } + return best; + } + + fn encode(cp: u21, fg: u4, bg: u4) Cell { + var c = Cell{ .fg = fg, .bg = bg }; + const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; + c.glyph_len = @intCast(n); + return c; + } + + pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, max_cols: usize, max_rows: usize, pal: Palette, ascii: bool) !Grid { + if (iw == 0 or ih == 0 or max_cols == 0 or max_rows == 0) return .{ .cells = try gpa.alloc(Cell, 0), .cols = 0, .rows = 0 }; + const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; + // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. + var cols = max_cols; + var rows = (max_cols * ih) / (2 * iw); + if (rows > max_rows) { + rows = max_rows; + cols = (max_rows * 2 * iw) / ih; + } + cols = std.math.clamp(cols, 1, max_cols); + rows = std.math.clamp(rows, 1, max_rows); + + const cells = try gpa.alloc(Cell, cols * rows); + var cy: usize = 0; + while (cy < rows) : (cy += 1) { + const ry0 = cy * ih / rows; + const ry1 = @max(ry0 + 1, (cy + 1) * ih / rows); + var cx: usize = 0; + while (cx < cols) : (cx += 1) { + const rx0 = cx * iw / cols; + const rx1 = @max(rx0 + 1, (cx + 1) * iw / cols); + var cell: [64][3]u8 = undefined; + var sy: usize = 0; + while (sy < 8) : (sy += 1) { + const py0 = ry0 + sy * (ry1 - ry0) / 8; + const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); + var sx: usize = 0; + while (sx < 8) : (sx += 1) { + const px0 = rx0 + sx * (rx1 - rx0) / 8; + const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); + var rs: usize = 0; + var gs: usize = 0; + var bs: usize = 0; + var n: usize = 0; + var yy = py0; + while (yy < py1 and yy < ih) : (yy += 1) { + var xx = px0; + while (xx < px1 and xx < iw) : (xx += 1) { + const i = (yy * iw + xx) * 4; + rs += rgba[i]; + gs += rgba[i + 1]; + bs += rgba[i + 2]; + n += 1; + } + } + if (n == 0) n = 1; + cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; + } + } + cells[cy * cols + cx] = matchCell(&cell, pal, gset); + } + } + return .{ .cells = cells, .cols = cols, .rows = rows }; + } + + test "sextant codepoints: blocks + endpoints" { + try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); + try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); + try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); + try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); + try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant + try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant + } + + test "matchCell: solid color -> space on that bg" { + var cell: [64][3]u8 = undefined; + for (&cell) |*p| p.* = commodore[5]; // all green + const m = matchCell(&cell, commodore, &glyphs); + try std.testing.expectEqual(@as(u4, 5), m.bg); + try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); + } + + test "matchCell: clean top/bottom split picks the two colors" { + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue + const m = matchCell(&cell, commodore, &glyphs); + // both palette colors must be chosen (in some fg/bg order) + const a = @as(u4, @min(m.fg, m.bg)); + const b = @as(u4, @max(m.fg, m.bg)); + try std.testing.expectEqual(@as(u4, 1), a); + try std.testing.expectEqual(@as(u4, 6), b); + // a clean top/bottom split must resolve to a real block glyph (the top-4-rows + // half block ▀), never a blank cell. + const cp = std.unicode.utf8Decode(m.glyph[0..m.glyph_len]) catch 0; + try std.testing.expectEqual(@as(u21, 0x2580), cp); + } + + test "ascii option only enables the ascii glyphs" { + // the ascii glyph codepoints must be reachable exactly when ascii is on. + var seen_block = false; + var seen_ascii = false; + for (glyphs) |g| if (g.cp == '#') { + seen_block = true; + }; + for (glyphs_ascii) |g| if (g.cp == '#') { + seen_ascii = true; + }; + try std.testing.expect(!seen_block); // '#' is an ascii-only glyph + try std.testing.expect(seen_ascii); + try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); + } + + test "ascii glyph is chosen when a cell has its exact shape" { + // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' + // with ascii on (cost 0), and fall back to some block glyph with ascii off. + const s_bits: u64 = 0x003c6630180c663c; + var cell: [64][3]u8 = undefined; + for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; + const on = matchCell(&cell, commodore, &glyphs_ascii); + try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glyph_len]) catch 0); + const off = matchCell(&cell, commodore, &glyphs); + try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glyph_len]) catch 0) != 'S'); + } + + test "render: tiny image produces a grid within bounds" { + const a = std.testing.allocator; + // 2x2 checker, RGBA + var img = [_]u8{0} ** (2 * 2 * 4); + img[0] = 255; + img[1] = 255; + img[2] = 255; + img[3] = 255; // (0,0) white + img[(3) * 4 + 0] = 255; + img[(3) * 4 + 1] = 255; + img[(3) * 4 + 2] = 255; + img[(3) * 4 + 3] = 255; // (1,1) white + const g = try render(a, &img, 2, 2, 10, 10, commodore, true); + defer a.free(g.cells); + try std.testing.expect(g.cols >= 1 and g.cols <= 10); + try std.testing.expect(g.rows >= 1 and g.rows <= 10); + try std.testing.expectEqual(g.cols * g.rows, g.cells.len); + } +}; + +pub const terminal_palette = [16][3]u8{ .{ 0x1D, 0x1F, 0x21 }, // black .{ 0xCC, 0x66, 0x66 }, // red .{ 0xB5, 0xBD, 0x68 }, // green @@ -44,18 +454,14 @@ const ansi_default = [16][3]u8{ }; comptime { - if (terminal_panes) for (ansi_default, 0..) |rgb, i| { + if (terminal_panes) for (terminal_palette, 0..) |rgb, i| { const c = ghostty_vt.color.default[i]; if (rgb[0] != c.r or rgb[1] != c.g or rgb[2] != c.b) @compileError( - "src/image.zig ansi_default has drifted from ghostty's color.default", + "terminal palette differs from the emulator", ); }; } -pub fn ansiPalette() [16][3]u8 { - return ansi_default; -} - /// cap the longest side before keeping/transmitting: a pane is at most a /// screenful of cells, multi-thousand-pixel photos waste decode/transmit time const MAX_DIM: u32 = 1280; diff --git a/src/image_pane.zig b/src/image_pane.zig deleted file mode 100644 index 71ef2228..00000000 --- a/src/image_pane.zig +++ /dev/null @@ -1,262 +0,0 @@ -//! Image panes: their owned pixels, PETSCII cache, lifecycle, and two render -//! paths. Core layout supplies the body rectangle; this module fills it or -//! attaches one native image to it. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const image = @import("image.zig"); -const look = @import("look.zig"); -const dump = @import("dump.zig"); -const config = @import("config.zig"); - -const GridKey = struct { - cols: u16 = 0, - rows: u16 = 0, - palette: image.PaletteMode = .commodore, - ascii: bool = true, -}; - -/// The decoded, downscaled RGBA is retained so changing the pane size or one -/// of the glyph-renderer toggles can rebuild the PETSCII projection without -/// decoding the source again. All slices are owned by Pardes.image_gpa. -pub const State = struct { - path: []u8, - petscii: bool = false, - pmode: image.PaletteMode = .commodore, - ascii: bool = true, - tried: bool = false, - rgba: []u8 = &.{}, - iw: usize = 0, - ih: usize = 0, - /// Dump-loaded bytes, decoded lazily by the same path as a disk image. - raw: []u8 = &.{}, - grid: image.petscii.Grid = .{ .cells = &.{}, .gw = 0, .gh = 0 }, - grid_key: GridKey = .{}, - - pub fn deinit(state: *State, gpa: std.mem.Allocator) void { - gpa.free(state.path); - if (state.rgba.len > 0) gpa.free(state.rgba); - if (state.raw.len > 0) gpa.free(state.raw); - if (state.grid.cells.len > 0) gpa.free(state.grid.cells); - state.* = undefined; - } -}; - -/// Construct an image pane from a path and optionally transferred dump bytes. -/// `raw` must be image_gpa-owned and ownership transfers only on success. -pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { - const path_copy = try p.image_gpa.dupe(u8, path); - errdefer p.image_gpa.free(path_copy); - const pane = try p.newDocPane(id); - pane.image = .{ .path = path_copy, .raw = raw }; - return pane; -} - -/// Serialize the binary image record used by images and, for dump-schema -/// compatibility, PDFs. Common pane metadata is supplied by the core. -pub fn dumpPane( - arena: std.mem.Allocator, - pane: *const pardes.Pane, - tag: []const u8, - body: []const u8, - scroll: usize, - path: []const u8, - raw: []const u8, -) !dump.Pane { - const bytes = if (raw.len > 0) raw else look.readFile(arena, path) catch ""; - return .{ - .kind = .image, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .image = .{ - .path = path, - .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", - .petscii = if (pane.image) |state| state.petscii else false, - .palette = if (pane.image) |state| switch (state.pmode) { - .commodore => .commodore, - .terminal => .terminal, - } else .commodore, - .ascii = if (pane.image) |state| state.ascii else true, - }, - }; -} - -/// Rebuild an ordinary raster image from its dump record. PDF records share -/// the schema for compatibility and are deliberately dispatched by the core -/// before reaching this function. -pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { - const saved = src.image.?; - var raw: []u8 = if (saved.bytes_b64.len > 0) - try dump.decodeBytes(p.image_gpa, saved.bytes_b64) - else - &.{}; - errdefer if (raw.len > 0) p.image_gpa.free(raw); - const pane = try create(p, id, saved.path, raw); - raw = &.{}; - pane.image.?.petscii = saved.petscii; - pane.image.?.pmode = switch (saved.palette) { - .commodore => .commodore, - .terminal => .terminal, - }; - pane.image.?.ascii = saved.ascii; - pane.cols = @max(1, src.cols); - pane.rows = @max(1, src.rows); - return pane; -} - -pub fn togglePetscii(state: *State) void { - state.petscii = !state.petscii; -} - -pub fn togglePalette(state: *State) void { - state.pmode = if (state.pmode == .commodore) .terminal else .commodore; -} - -pub fn toggleAscii(state: *State) void { - state.ascii = !state.ascii; -} - -/// Image renderer choices are pane-local, not global Config values. Keep them -/// queryable where they apply: the live tag beside the image's path. -pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { - return std.fmt.allocPrint( - arena, - "{s} petscii:{s} palette:{s} ascii:{s} {s}", - .{ - config.tag_image, - if (state.petscii) "on" else "off", - @tagName(state.pmode), - if (state.ascii) "on" else "off", - state.path, - }, - ); -} - -/// Prefix written by version-1 dumps before renderer choices became visible -/// in the live tag. Returned as a borrowed slice of saved_tag for one-time -/// custom-tail migration. -pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { - const lead = config.tag_image ++ " "; - if (!std.mem.startsWith(u8, saved_tag, lead)) return null; - const path_at = lead.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; - return saved_tag[0 .. path_at + state.path.len]; -} - -fn ensureDecoded(p: *pardes.Pardes, state: *State) void { - if (state.tried) return; - state.tried = true; - const bytes: []const u8 = if (state.raw.len > 0) - state.raw - else - look.readFile(p.scratch.allocator(), state.path) catch ""; - if (image.decode(p.image_gpa, bytes)) |decoded| { - state.rgba = decoded.rgba; - state.iw = decoded.w; - state.ih = decoded.h; - } -} - -fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { - const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; - if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; - if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); - const palette = switch (state.pmode) { - .commodore => image.petscii.commodore, - .terminal => image.ansiPalette(), - }; - state.grid = image.petscii.render( - p.image_gpa, - state.rgba, - state.iw, - state.ih, - cols, - rows, - palette, - state.ascii, - ) catch .{ .cells = &.{}, .gw = 0, .gh = 0 }; - state.grid_key = wanted; -} - -/// Render the image body supplied by core layout. Native-capable hosts receive -/// a pixel attachment; other hosts and explicit PETSCII mode receive cells. -pub fn draw( - p: *pardes.Pardes, - state: *State, - pane_id: u8, - serial: u32, - x: u16, - y: u16, - cols: u16, - rows: u16, -) void { - ensureDecoded(p, state); - if (state.rgba.len == 0 or cols == 0 or rows == 0) return; - if (!state.petscii and p.native_images) { - _ = p.appendImagePlace(.{ - .pane = pane_id, - .serial = serial, - .x = x, - .y = y, - .w = cols, - .h = rows, - .rgba = state.rgba, - .iw = state.iw, - .ih = state.ih, - }); - return; - } - - ensureGrid(p, state, cols, rows); - if (state.grid.cells.len == 0) return; - const offx = if (cols > state.grid.gw) (@as(usize, cols) - state.grid.gw) / 2 else 0; - const offy = if (rows > state.grid.gh) (@as(usize, rows) - state.grid.gh) / 2 else 0; - for (0..state.grid.gh) |cy| for (0..state.grid.gw) |cx| { - const cell = &state.grid.cells[cy * state.grid.gw + cx]; - const fg: pardes.Color = switch (state.pmode) { - .commodore => .{ .rgb = image.petscii.commodore[cell.fg] }, - .terminal => .{ .index = cell.fg }, - }; - const bg: pardes.Color = switch (state.pmode) { - .commodore => .{ .rgb = image.petscii.commodore[cell.bg] }, - .terminal => .{ .index = cell.bg }, - }; - const sx = x + @as(u16, @intCast(offx + cx)); - const sy = y + @as(u16, @intCast(offy + cy)); - if (sx < p.surface.cols and sy < p.surface.rows) - p.surface.set(sx, sy, cell.glyph[0..cell.glen], .{ .fg = fg, .bg = bg }); - }; -} - -test "pane-local renderer choices are visible in the image tag" { - var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; - togglePetscii(&state); - togglePalette(&state); - toggleAscii(&state); - const tag = try tagPrefix(std.testing.allocator, &state); - defer std.testing.allocator.free(tag); - try std.testing.expectEqualStrings( - config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", - tag, - ); - try std.testing.expectEqualStrings( - "img /tmp/picture.ppm", - legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, - ); -} - -test "dump restore propagates malformed embedded image bytes" { - const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - try std.testing.expect(p.panes[1] == null); - try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{ - .kind = .image, - .tag = "img /tmp/bad.ppm", - .body = "", - .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" }, - })); - try std.testing.expect(p.panes[1] == null); -} diff --git a/src/layout.zig b/src/layout.zig new file mode 100644 index 00000000..51944856 --- /dev/null +++ b/src/layout.zig @@ -0,0 +1,1706 @@ +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const panes = @import("panes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 }; +pub const Snapshot = struct { serial: u32, box: Box }; +const MAX_COLS = pardes.MAX_COLS; +const MAX_PANES = pardes.MAX_PANES; +const TOPBAR_H = pardes.TOPBAR_H; +const BOX_H = pardes.BOX_H; + +pub const Presentation = struct { + // Only acknowledged draws advance shown geometry; committed and submitted may be ahead. + committed: [MAX_PANES]?Snapshot = @splat(null), + initialized: bool = false, + enabled: bool = false, + snap_once: bool = false, + tracks: [MAX_PANES]?Track = @splat(null), + closing: [MAX_PANES]Track = undefined, + closing_len: usize = 0, + submitted: [MAX_PANES]?Snapshot = @splat(null), + submitted_ready: bool = false, + shown: [MAX_PANES]?Snapshot = @splat(null), + shown_tracks: [MAX_PANES]?Track = @splat(null), + shown_closing: [MAX_PANES]Track = undefined, + shown_closing_len: usize = 0, + acknowledged: bool = false, + pending: bool = false, + previous_cells: []pardes.Cell = &.{}, + previous_cols: u16 = 0, + previous_rows: u16 = 0, + previous_valid: bool = false, + previous_layout: [MAX_PANES]?Snapshot = @splat(null), + diffs: []pardes.PanelCellDiff = &.{}, + diff_state: enum { none, requested, ready } = .none, + + pub const CellPosition = struct { col: u16, row: u16 }; + + pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void { + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + if (self.diffs.len > 0) gpa.free(self.diffs); + } + + pub fn sync(self: *Presentation, p: *Pardes) void { + const initializing = !self.initialized or !self.enabled; + if (initializing or self.snap_once) { + const had_layout = self.initialized; + self.committed = @splat(null); + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + if (initializing) { + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + } + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + self.initialized = true; + self.snap_once = false; + if (had_layout and self.acknowledged) + self.pending = true; + return; + } + + const effect = p.settings.panel_transition; + if (effect.needsPreviousGrid() and self.diff_state != .none) { + var second_change = false; + for (p.panes, self.committed, 0..) |slot, snapshot, id| { + const pane = slot orelse { + second_change = second_change or snapshot != null; + continue; + }; + const target = panelBox(p.rects[id]); + second_change = second_change or snapshot == null or + snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); + } + if (second_change) { + p.abandonPanelAnimations(); + self.committed = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + return; + } + } + var changed = false; + var animated_change = false; + for (p.panes, 0..) |slot, id| { + const pane = slot orelse { + if (self.committed[id]) |old| { + changed = true; + if (effect.lifecycleOnly()) { + if (self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + })) animated_change = true; + } + } + self.committed[id] = null; + self.tracks[id] = null; + // Never let pixels from a dead pane address a reused slot. + self.shown_tracks[id] = null; + continue; + }; + const target = panelBox(p.rects[id]); + const previous = self.committed[id]; + self.committed[id] = .{ .serial = pane.serial, .box = target }; + + if (effect == .off) { + changed = changed or previous == null or previous.?.serial != pane.serial or + !previous.?.box.eql(target); + self.tracks[id] = null; + continue; + } + if (previous) |old| { + if (old.serial == pane.serial and old.box.eql(target)) continue; + const same_lifetime = old.serial == pane.serial; + const prior = if (self.tracks[id]) |track| + if (track.serial == pane.serial and track.active()) track else null + else + null; + if (effect.lifecycleOnly() and same_lifetime) { + if (prior) |active| if (active.phase == .opening) { + var next = active; + next.from = openingBox(effect, target, p.screen_w); + next.to = target; + self.tracks[id] = next; + changed = true; + animated_change = true; + continue; + }; + self.tracks[id] = null; + changed = true; + continue; + } + if (effect.lifecycleOnly() and !same_lifetime) { + _ = self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + }); + } + const shown = if (self.shown[id]) |snapshot| + if (snapshot.serial == pane.serial) snapshot.box else null + else + null; + const from = if (!same_lifetime) + openingBox(effect, target, p.screen_w) + else if (shown) |box| + box + else if (self.acknowledged and prior != null) + prior.?.from + else + old.box; + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = if (!same_lifetime or + (prior != null and prior.?.phase == .opening)) .opening else .moving, + .effect = effect, + .from = from, + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } else { + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = .opening, + .effect = effect, + .from = openingBox(effect, target, p.screen_w), + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } + } + if (animated_change and effect.needsPreviousGrid()) { + self.diff_state = .requested; + } + if (changed and self.acknowledged) self.pending = true; + } + + fn appendClosing(self: *Presentation, track: Track) bool { + std.debug.assert(track.phase == .closing); + const baseline = self.previous_layout[track.pane] orelse return false; + if (!self.previous_valid or baseline.serial != track.serial or + !baseline.box.eql(track.from)) return false; + if (self.closing_len == self.closing.len) { + std.mem.copyForwards( + Track, + self.closing[0 .. self.closing.len - 1], + self.closing[1..], + ); + self.closing_len -= 1; + } + self.closing[self.closing_len] = track; + self.closing_len += 1; + return true; + } + + pub fn advance(self: *Presentation) void { + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + track.frame +|= 1; + if (!track.active()) slot.* = null; + } + var out: usize = 0; + for (self.closing[0..self.closing_len]) |value| { + var track = value; + track.frame +|= 1; + if (!track.active()) continue; + self.closing[out] = track; + out += 1; + } + self.closing_len = out; + } + + pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void { + var presented: [MAX_PANES]?Track = @splat(null); + var presented_closing: [MAX_PANES]Track = undefined; + var nclosing: usize = 0; + var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready) + self.submitted + else + @splat(null); + if (!self.submitted_ready) for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + }; + for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { + const pane = p.panes[id] orelse { + snapshot.* = null; + continue; + }; + if (pane.serial != saved.serial) snapshot.* = null; + }; + for (tracks) |track| { + if (track.phase == .closing) { + const current = for (self.closing[0..self.closing_len]) |candidate| { + if (candidate.serial == track.serial and candidate.pane == track.pane and + candidate.effect == track.effect) break true; + } else false; + if (!current or !track.active() or nclosing == presented_closing.len) continue; + presented_closing[nclosing] = track; + nclosing += 1; + continue; + } + const id: usize = track.pane; + if (id >= p.panes.len or !track.active()) continue; + const pane = p.panes[id] orelse continue; + if (pane.serial != track.serial) continue; + presented[id] = track; + presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; + } + self.shown_tracks = presented; + self.shown_closing = presented_closing; + self.shown_closing_len = nclosing; + self.shown = presented_layout; + self.acknowledged = true; + self.pending = false; + if (tracks.len == 0) { + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + self.capturePrevious(p.gpa, &p.surface); + } + } + + fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { + self.diff_state = .none; + const cells = surface.cells; + if (cells.len == 0) { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + } + if (self.previous_cells.len != cells.len) { + const next = gpa.alloc(pardes.Cell, cells.len) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + self.previous_cells = next; + } + @memcpy(self.previous_cells, cells); + self.previous_cols = surface.cols; + self.previous_rows = surface.rows; + self.previous_valid = true; + self.previous_layout = if (self.submitted_ready) + self.submitted + else + @splat(null); + } + + pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff { + if (self.diff_state != .ready or col >= cols or row >= rows or + self.diffs.len != @as(usize, cols) * rows) return .unchanged; + return self.diffs[@as(usize, row) * cols + col]; + } + + pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition { + if (self.pending) return null; + var closing = self.shown_closing_len; + while (closing > 0) { + closing -= 1; + const track = self.shown_closing[closing]; + if (!track.active()) continue; + if (boxContainsCell(track.contentBox(), col, row) and + boxContainsCell(track.presented(), col, row)) return null; + } + const phases = [_]Phase{ .opening, .moving }; + for (phases) |phase| { + // Backends paint pane slots forward within a phase. Probe them in + // reverse so overlapping transition quads address the top pixel. + var index = self.shown_tracks.len; + while (index > 0) { + index -= 1; + const track = self.shown_tracks[index] orelse continue; + if (!track.active() or track.phase != phase) continue; + switch (track.effect) { + .slide, .zoom => { + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); + const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); + return .{ + .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))), + .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))), + }; + }, + .vertical => { + const clip = track.contentBox(); + if (!boxContainsCell(clip, col, row)) continue; + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) + return null; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + return .{ + .col = @intFromFloat(@floor(clip.x + u * clip.w)), + .row = @intFromFloat(@floor(clip.y + v * clip.h)), + }; + }, + .dissolve, .ascii => { + if (!boxContainsCell(track.to, col, row)) continue; + const cell_diff = self.cellDiff(cols, rows, col, row); + if (!cell_diff.changed()) + return .{ .col = col, .row = row }; + const relative_col: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, + )); + const relative_row: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, + )); + const visible = switch (track.effect) { + .dissolve => dissolveRevealed( + track.serial, + relative_col, + relative_row, + track.amount(), + ), + .ascii => switch (cell_diff) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, + else => unreachable, + }; + return if (visible) .{ .col = col, .row = row } else null; + }, + .edges, .fall, .wave, .curtain, .scramble, .typewriter => { + if (!boxContainsCell(track.to, col, row)) continue; + const area = CellArea.of(track.to); + const settled = std.meta.eql( + charSource( + track, + col -| area.x0, + row -| area.y0, + area, + ), + CharSource.settled, + ); + return if (settled) .{ .col = col, .row = row } else null; + }, + .off => {}, + } + } + } + for (self.shown_tracks) |maybe| { + const track = maybe orelse continue; + if (!track.active() or (track.effect != .slide and track.effect != .zoom and + track.effect != .vertical)) continue; + if (boxContainsCell(track.to, col, row)) return null; + } + return .{ .col = col, .row = row }; + } + + fn boxContainsCell(box: Box, col: u16, row: u16) bool { + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } + + pub fn cancel(self: *Presentation) void { + self.tracks = @splat(null); + self.closing_len = 0; + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + self.diff_state = .none; + self.pending = self.acknowledged; + } + + fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool { + const count = surface.cells.len; + if (!self.previous_valid or + self.previous_cols != surface.cols or + self.previous_rows != surface.rows or + self.previous_cells.len != count) return false; + if (self.diffs.len != count) { + const next = try gpa.alloc(pardes.PanelCellDiff, count); + if (self.diffs.len > 0) gpa.free(self.diffs); + self.diffs = next; + } + for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new| + diff.* = pardes.PanelCellDiff.between(old, new); + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + if (track.effect != .ascii) continue; + var longest: u16 = 1; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * surface.cols + col; + switch (self.diffs[index]) { + .ascii => |diff| longest = @max(longest, diff.frameCount()), + .unchanged, .visual => {}, + } + } + } + track.frame_count = @max(track.frame_count, longest); + } + self.diff_state = .ready; + return true; + } + + pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { + self.submitted = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.submitted[id] = .{ + .serial = pane.serial, + .box = panelBox(p.rects[id]), + }; + } + self.submitted_ready = true; + if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) { + for (&self.tracks) |*slot| { + const track = slot.* orelse continue; + if (track.effect.needsPreviousGrid()) slot.* = null; + } + self.closing_len = 0; + self.diff_state = .none; + } + if (self.diff_state == .ready) { + s.previous_cells = self.previous_cells; + s.cell_diffs = self.diffs; + } + s.npanel_tracks = paintOrder( + &self.tracks, + self.closing[0..self.closing_len], + &s.panel_tracks, + ); + } + + pub fn animating(self: *const Presentation) bool { + for (self.tracks) |track| if (track != null and track.?.active()) return true; + for (self.closing[0..self.closing_len]) |track| + if (track.active()) return true; + return false; + } + + test "presentation cache allocation failures retain ownership and allow retry" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const gpa = allocator.allocator(); + var state: Presentation = .{}; + defer state.deinit(gpa); + var cells: [3]pardes.Cell = @splat(.{}); + cells[0].default = false; + cells[0].text[0] = 'a'; + var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] }; + state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }; + state.submitted_ready = true; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial); + const previous = state.previous_cells.ptr; + const allocated = allocator.allocations; + allocator.fail_index = allocator.alloc_index; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(allocated, allocator.allocations); + try std.testing.expect(!allocator.has_induced_failure); + + surface.cols = 3; + surface.cells = &cells; + state.submitted[0].?.box.w = 3; + state.diff_state = .ready; + state.capturePrevious(gpa, &surface); + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expect(!state.previous_valid); + try std.testing.expectEqual(previous, state.previous_cells.ptr); + try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len); + try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]); + try std.testing.expect(state.previous_layout[0] == null); + try std.testing.expectEqual(.none, state.diff_state); + + allocator.fail_index = std.math.maxInt(usize); + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len); + try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w); + state.diff_state = .requested; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.requested, state.diff_state); + try std.testing.expectEqual(@as(usize, 0), state.diffs.len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(try state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.ready, state.diff_state); + try std.testing.expectEqual(@as(usize, 3), state.diffs.len); + for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff); + } + + test "presentation submission failure preserves shown geometry until retry is acknowledged" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(&.{}); + const shown = p.presentation.shown; + const previous = p.presentation.previous_cells.ptr; + p.presentation.diff_state = .requested; + p.presentation.pending = true; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface)); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr); + try std.testing.expectEqual(.requested, p.presentation.diff_state); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null); + + allocator.fail_index = std.math.maxInt(usize); + try p.presentation.submit(p, &p.surface); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expect(p.presentation.pending); + p.acknowledgePanelPresentation(&.{}); + try std.testing.expect(!p.presentation.pending); + try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); + } +}; + +pub const column_weight_unit: u64 = 1 << 32; +pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; + +pub const MovePlacement = struct { + preview_col: usize, + above_id: usize, + row: u16, + above_y: u16, + above_h: u16, +}; + +pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { + const a = p.rects[from]; + var best: ?usize = null; + var best_d: i32 = 0; + for (p.panes, 0..) |slot, i| { + if (slot == null or i == from) continue; + const r = p.rects[i]; + const vov = a.y < r.y + r.h and r.y < a.y + a.h; + const hov = a.x < r.x + r.w and r.x < a.x + a.w; + const ok = switch (dir) { + .left => r.x + r.w <= a.x and vov, + .right => r.x >= a.x + a.w and vov, + .up => r.y + r.h <= a.y and hov, + .down => r.y >= a.y + a.h and hov, + }; + if (!ok) continue; + const d: i32 = switch (dir) { + .left => @as(i32, a.x) - @as(i32, r.x + r.w), + .right => @as(i32, r.x) - @as(i32, a.x + a.w), + .up => @as(i32, a.y) - @as(i32, r.y + r.h), + .down => @as(i32, r.y) - @as(i32, a.y + a.h), + }; + if (best == null or d < best_d) { + best = i; + best_d = d; + } + } + if (best) |b| { + p.active = b; + // a count typed before the hop was meant for the pane you left + p.panes[b].?.normal.clear(); + } +} + +pub fn targetColumn(p: *Pardes, cur_x: u16) usize { + var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; + for (0..p.ncol) |c| { + if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { + tc = c; + break; + } + } + return tc; +} + +pub fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { + if (h < 2) return null; + const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; + const lo = y +| min_each; + const hi = y + h - min_each; + if (lo > hi) return y + h / 2; + return std.math.clamp(cur_y, lo, hi); +} + +pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { + const src = findPane(p, id) orelse return null; + const tc = targetColumn(p, cur_x); + if (tc == src.col and p.col_n[src.col] == 1) return null; + if (tc == src.col) { + const sr = p.rects[id]; + if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; + } + var heights: [MAX_PANES]u16 = @splat(0); + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + heights[pid] = p.rects[pid].h; + } + } + if (p.col_n[src.col] > 1) { + const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx + 1]; + heights[sib] +|= p.rects[id].h; + } + var y: u16 = TOPBAR_H; + var last: ?MovePlacement = null; + for (0..p.col_n[tc]) |k| { + const pid = p.col_panes[tc][k]; + if (pid == id) continue; + const h = heights[pid]; + const row = splitRowForExtent(y, h, cur_y) orelse { + y +|= h; + continue; + }; + const placement: MovePlacement = .{ + .preview_col = tc, + .above_id = pid, + .row = row, + .above_y = y, + .above_h = h, + }; + last = placement; + if (cur_y < y + h) return placement; + y +|= h; + } + return last; +} + +pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { + const placement = movePlacement(p, id, cur_x, cur_y) orelse return; + const src = findPane(p, id) orelse return; + const source_multi = p.col_n[src.col] > 1; + var heights: [MAX_PANES]u16 = @splat(0); + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + heights[pid] = p.rects[pid].h; + } + } + removePane(p, id); + if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { + const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx]; + heights[sib] +|= p.rects[id].h; + } + const af = findPane(p, placement.above_id) orelse return; + const upper_h = @max(1, placement.row -| placement.above_y); + const lower_h = @max(1, placement.above_h -| upper_h); + heights[placement.above_id] = upper_h; + heights[id] = lower_h; + insert(p, af.col, af.idx + 1, id); + setColumnWeights(p, af.col, &heights); + if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights); +} + +pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { + if (col >= p.ncol) return; + for (0..p.col_n[col]) |k| { + const pid = p.col_panes[col][k]; + if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); + } +} + +pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { + if (k + 1 >= p.col_n[cc]) return; + const a = p.panes[p.col_panes[cc][k]] orelse return; + const b = p.panes[p.col_panes[cc][k + 1]] orelse return; + const ra = p.rects[p.col_panes[cc][k]]; + const rb = p.rects[p.col_panes[cc][k + 1]]; + const combined: f32 = @floatFromInt(ra.h + rb.h); + var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); + nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); + const pair = a.vweight + b.vweight; + a.vweight = pair * (nt / combined); + b.vweight = pair - a.vweight; +} + +pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { + for (0..p.ncol) |c| { + for (0..p.col_n[c]) |k| { + if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k }; + } + } + return null; +} + +pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void { + var k = p.col_n[c]; + while (k > idx) : (k -= 1) p.col_panes[c][k] = p.col_panes[c][k - 1]; + p.col_panes[c][idx] = id; + p.col_n[c] += 1; +} + +pub fn removePane(p: *Pardes, id: usize) void { + const f = findPane(p, id) orelse return; + const c = f.col; + var k = f.idx; + while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; + p.col_n[c] -= 1; + if (p.col_n[c] == 0) { + if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_panes[j] = p.col_panes[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; + } +} + +pub fn joinCol(p: *Pardes) void { + const f = findPane(p, p.active) orelse return; + const c = f.col; + if (c + 1 >= p.ncol) return; + const dst = c + 1; + p.col_weight[dst] +|= p.col_weight[c]; + for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; + p.col_n[dst] += p.col_n[c]; + var j = c; + while (j + 1 < p.ncol) : (j += 1) { + p.col_panes[j] = p.col_panes[j + 1]; + p.col_n[j] = p.col_n[j + 1]; + p.col_weight[j] = p.col_weight[j + 1]; + } + p.ncol -= 1; +} + +pub fn canSplitColumn(p: *Pardes, source_id: usize) bool { + if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; + const source = findPane(p, source_id) orelse return false; + // Refresh derived widths: public layout surgery may be chained between + // syncs, and a cached width must never admit a now-too-narrow split. + compute(p); + if (p.col_w[source.col] < config.MINW * 2) return false; + + const weight = p.col_weight[source.col]; + if (weight >= 2 and weight % 2 == 0) return true; + for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) + return false; + return weight > 0; +} + +pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { + if (id >= MAX_PANES or p.panes[id] == null) return false; + if (!canSplitColumn(p, source_id)) return false; + const source = findPane(p, source_id) orelse return false; + const source_col = source.col; + var old_weight = p.col_weight[source_col]; + const needs_rebase = old_weight < 2 or old_weight % 2 != 0; + if (needs_rebase) old_weight *= 2; + if (id == source_id) { + if (p.col_n[source_col] <= 1) return false; + absorbVWeight(p, id); + removePane(p, id); + } else if (findPane(p, id) != null) return false; + + if (needs_rebase) { + for (0..p.ncol) |column| p.col_weight[column] *= 2; + } + const source_weight = old_weight / 2; + const new_weight = old_weight - source_weight; + p.col_weight[source_col] = source_weight; + const c = source_col + @intFromBool(!before); + var j = p.ncol; + while (j > c) : (j -= 1) { + p.col_panes[j] = p.col_panes[j - 1]; + p.col_n[j] = p.col_n[j - 1]; + p.col_weight[j] = p.col_weight[j - 1]; + } + p.col_weight[c] = new_weight; + p.col_panes[c][0] = id; + p.col_n[c] = 1; + p.ncol += 1; + return true; +} + +pub fn snapColWeights(p: *Pardes, c: usize) void { + for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + } +} + +pub fn absorbVWeight(p: *Pardes, id: usize) void { + const f = findPane(p, id) orelse return; + if (p.col_n[f.col] <= 1) return; + snapColWeights(p, f.col); + var sib = if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1]; + var k = f.idx; + while (k > 0) : (k -= 1) { + sib = p.col_panes[f.col][k - 1]; + if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break; + } + if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); +} + +pub fn splitParent(p: *Pardes, want: usize) usize { + const need = 2 * BOX_H + 3; + if (p.rects[want].h >= need) return want; + if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| { + if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k]; + }; + var tallest = want; + for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { + const pid = p.col_panes[c][k]; + if (p.rects[pid].h >= need) return pid; + if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; + }; + return tallest; +} + +pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { + const src = p.panes[src_id] orelse return; + const src_h = p.rects[src_id].h; + const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 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) + const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); + if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| { + const pid = p.col_panes[f.col][k]; + if (p.panes[pid]) |pp| if (pp != nw) { + pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + }; + }; + src.vweight = @floatFromInt(BOX_H + keep); + nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); + if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) { + // trimmed: every row ends in a newline, and the empty line after + // the last one is not a result + const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); + if (want < nw.vweight) { + src.vweight += nw.vweight - want; + nw.vweight = want; + } + }; +} + +pub fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { + const f = findPane(p, source_id) orelse return false; + compute(p); + return p.col_w[f.col] >= min_cells * 2; +} + +pub fn panelBox(rect: Rect) Box { + return .{ + .x = @floatFromInt(rect.x), + .y = @floatFromInt(rect.y), + .w = @floatFromInt(rect.w), + .h = @floatFromInt(rect.h), + }; +} + +pub fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { + if (total == 0) return 0; + const pixels = (@as(u128, width) * prefix + total / 2) / total; + return @intCast(@min(@as(u128, width), pixels)); +} + +pub fn compute(p: *Pardes) void { + p.rects = @splat(.{}); + if (p.ncol == 0) return; + var wsum: u128 = 0; + for (0..p.ncol) |c| wsum += p.col_weight[c]; + if (wsum == 0) wsum = 1; + + // Round cumulative boundaries so widths still sum to the available screen. + var prefix: u128 = 0; + for (0..p.ncol) |c| { + const last = c + 1 == p.ncol; + const x = columnBoundary(p.screen_w, prefix, wsum); + prefix += p.col_weight[c]; + const end: u16 = if (last) + p.screen_w + else + columnBoundary(p.screen_w, prefix, wsum); + const cw = end -| x; + p.col_x[c] = x; + p.col_w[c] = cw; + + var vsum: f32 = 0; + for (0..p.col_n[c]) |k| { + if (p.panes[p.col_panes[c][k]]) |pane| vsum += pane.vweight; + } + if (vsum <= 0) vsum = 1; + + var y: u16 = TOPBAR_H; + const avail_h = p.screen_h -| TOPBAR_H; + for (0..p.col_n[c]) |k| { + const id = p.col_panes[c][k]; + const pane = p.panes[id] orelse continue; + const lastk = k + 1 == p.col_n[c]; + const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; + const room = p.screen_h -| y; + const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); + p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; + y +|= ch; + } + } +} + +pub const ascii_max_movement_frames: u16 = 12; + +pub const Easing = enum(u8) { + linear, + smooth, + /// Quintic ease-in-out. It creeps at both ends and crosses the middle of + /// the distance fast, inside the same frame count a linear walk would use. + smoother, + in_cubic, + out_cubic, + out_back, +}; + +pub const Transition = enum(u8) { + // Numeric values are shared with the GUI shader ABI. + off = 0, + slide = 1, + zoom = 2, + dissolve = 3, + ascii = 4, + vertical = 5, + edges = 6, + fall = 7, + wave = 8, + curtain = 9, + scramble = 10, + typewriter = 11, + + pub fn easing(effect: Transition) Easing { + return switch (effect) { + .off, .dissolve, .wave => .smooth, + .slide, .vertical, .edges => .out_cubic, + .zoom => .out_back, + // Character walks and per-cell locks read best with a slow start, + // a fast middle, and a slow settle over their fixed frame count. + .ascii, .fall, .scramble => .smoother, + // A sweep and a typewriter are constant-rate by definition: easing + // their head would make the pass visibly hesitate mid-pane. + .curtain, .typewriter => .linear, + }; + } + + pub fn frames(effect: Transition) u16 { + return switch (effect) { + .off => 0, + .slide => 12, + .zoom => 14, + .dissolve => 10, + .ascii => ascii_max_movement_frames + 1, + .vertical => 12, + .edges, .curtain, .scramble => 12, + // Travelling motion needs a couple more samples than a lock or a + // rigid slide before it stops reading as a jump. + .fall, .wave, .typewriter => 14, + }; + } + + pub fn composedByCore(effect: Transition) bool { + return switch (effect) { + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, + .off, .slide, .zoom, .dissolve, .vertical => false, + }; + } + + pub fn needsPreviousGrid(effect: Transition) bool { + return effect == .dissolve or effect == .vertical or effect.composedByCore(); + } + + pub fn lifecycleOnly(effect: Transition) bool { + return effect == .vertical; + } +}; + +pub const SceneEffect = struct { + crt: bool = false, + ripple: bool = false, + glitch: bool = false, +}; + +pub const Phase = enum(u8) { + opening = 0, + moving = 1, + /// Presentation-only content whose pane lifetime has already ended. + /// It is never a valid input target. + closing = 2, +}; + +pub const Box = extern struct { + x: f32 = 0, + y: f32 = 0, + w: f32 = 0, + h: f32 = 0, + + pub fn eql(a: Box, b: Box) bool { + return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; + } + + // A cell belongs to a fractional box when its center lies inside. + pub fn contains(box: Box, col: u16, row: u16) bool { + const x: f32 = @floatFromInt(col); + const y: f32 = @floatFromInt(row); + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } +}; + +// Drawing and hit testing share this moving/opening/closing order. +pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { + var len: usize = 0; + for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { + const track = maybe orelse continue; + if (!track.active() or track.phase != phase) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + }; + for (closing) |track| { + if (!track.active()) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + } + return len; +} + +/// One POD record is enough for every backend. `from` and `to` are logical +/// cell boxes; frontends convert them to pixels only at their render edge. +pub const Track = extern struct { + serial: u32 = 0, + pane: u8 = 0, + phase: Phase = .moving, + effect: Transition = .off, + _padding: u8 = 0, + frame: u16 = 0, + frame_count: u16 = 0, + from: Box = .{}, + to: Box = .{}, + + pub fn active(track: Track) bool { + return track.effect != .off and track.frame < track.frames(); + } + + pub fn frames(track: Track) u16 { + return if (track.frame_count != 0) track.frame_count else track.effect.frames(); + } + + pub fn amount(track: Track) f32 { + // Opening rises quickly and settles; closing reverses that motion and + // accelerates down out of the fixed clip. + if (track.phase == .closing and track.effect == .vertical) + return progressEased(.in_cubic, track.frames(), track.frame); + return progressEased(track.effect.easing(), track.frames(), track.frame); + } + + pub fn presented(track: Track) Box { + return lerpBox(track.from, track.to, track.amount()); + } + + pub fn visualBox(track: Track) Box { + return switch (track.effect) { + .slide, .zoom, .vertical => track.presented(), + .off, .dissolve => track.to, + // Every character effect stays inside the pane's final rectangle. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, + }; + } + + pub fn contentBox(track: Track) Box { + return if (track.phase == .closing) track.from else track.to; + } +}; + +pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { + return switch (effect) { + .slide => blk: { + var from = target; + const middle = target.x + target.w * 0.5; + from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) + -target.w + else + @floatFromInt(screen_width); + break :blk from; + }, + .zoom => .{ + .x = target.x + target.w * 0.5, + .y = target.y + target.h * 0.5, + .w = 0, + .h = 0, + }, + .vertical => blk: { + var from = target; + from.y += target.h; + break :blk from; + }, + .off, .dissolve => target, + // Character effects own the glyphs inside a fixed rectangle, so their + // panel opens at exactly its final geometry. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, + }; +} + +pub fn closingBox(effect: Transition, source: Box) Box { + return switch (effect) { + .vertical => blk: { + var to = source; + to.y += source.h; + break :blk to; + }, + else => source, + }; +} + +pub fn sample(easing: Easing, raw: f32) f32 { + const t = std.math.clamp(raw, 0.0, 1.0); + return switch (easing) { + .linear => t, + .smooth => t * t * (3.0 - 2.0 * t), + .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), + .in_cubic => t * t * t, + .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + // Robert Penner's ease-out-back polynomial. It intentionally travels + // a little past one before settling exactly on the endpoint. + .out_back => blk: { + const c1: f32 = 1.70158; + const c3 = c1 + 1.0; + const u = t - 1.0; + break :blk 1.0 + c3 * u * u * u + c1 * u * u; + }, + }; +} + +pub fn progress(effect: Transition, frame: u16) f32 { + return progressEased(effect.easing(), effect.frames(), frame); +} + +fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { + if (frames <= 1 or frame >= frames - 1) return 1.0; + return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); +} + +pub fn lerpBox(from: Box, to: Box, t: f32) Box { + const u = @max(0.0, t); + return .{ + .x = from.x + (to.x - from.x) * u, + .y = from.y + (to.y - from.y) * u, + .w = @max(0.0, from.w + (to.w - from.w) * u), + .h = @max(0.0, from.h + (to.h - from.h) * u), + }; +} + +/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only +/// hashing means resizing or repainting a frame does not make cells flicker. +pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { + var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); + x ^= x >> 16; + x *%= 0x7feb_352d; + x ^= x >> 15; + x *%= 0x846c_a68b; + x ^= x >> 16; + return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; +} + +/// Whether a changed dissolve cell has crossed from the frozen old grid to +/// the new one. Exact endpoints are part of the presentation contract. +pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { + const t = std.math.clamp(raw_progress, 0.0, 1.0); + if (t <= 0) return false; + if (t >= 1) return true; + return cellNoise(serial, col, row) < t; +} + +pub const CellArea = struct { + x0: u16 = 0, + y0: u16 = 0, + cols: u16 = 1, + rows: u16 = 1, + + pub fn of(box: Box) CellArea { + return .{ + .x0 = floorCell(box.x), + .y0 = floorCell(box.y), + .cols = ceilCell(box.w), + .rows = ceilCell(box.h), + }; + } +}; + +fn floorCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); +} + +fn ceilCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); +} + +pub const CharSource = union(enum) { + /// Nothing has arrived here yet: keep the frozen old cell. + old, + at: Offset, + /// Paint this printable byte in the destination cell's own style, whatever + /// that cell holds — a caret marching over empty space is still a caret. + byte: u8, + churn: u8, + + pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; + + pub const settled: CharSource = .{ .at = .{} }; +}; + +pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { + const t = track.amount(); + if (t >= 1.0) return .settled; + const w: f32 = @floatFromInt(area.cols); + const h: f32 = @floatFromInt(area.rows); + const c: f32 = @floatFromInt(col); + const r: f32 = @floatFromInt(row); + const remaining = 1.0 - t; + return switch (track.effect) { + .edges => blk: { + const travel = cellsOf(remaining * (w + 1.0)); + break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; + }, + // Columns rain down, each with its own stable head start, so the pane + // fills from the top and the last glyphs land at the bottom. + .fall => blk: { + const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); + if (local <= 0.0) break :blk .old; + break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; + }, + // A vertical ripple travels left to right and its amplitude decays, so + // the pane settles out of a wave instead of a fade. + .wave => .{ .at = .{ + .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), + } }, + // A curtain of glyphs marches in from the right, column by column, left + // to right; each column still has a short slide of its own. + .curtain => blk: { + const lead = t * (w + 1.0) - c; + if (lead <= 0.0) break :blk .old; + break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; + }, + // Every cell churns through printable ASCII and locks onto its final + // glyph at its own stable threshold: the pane resolves out of noise. + .scramble => blk: { + if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; + const churn = cellNoise( + track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), + col, + row, + ); + break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; + }, + // Reading-order reveal with a caret sitting on the write head. + .typewriter => blk: { + const head = t * w * h; + const index = r * w + c; + if (index + 1.0 <= head) break :blk .settled; + if (index <= head) break :blk .{ .byte = '_' }; + break :blk .old; + }, + // PanelAscii walks its own byte distance per cell, and the geometry + // effects never reach this path at all. + .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, + }; +} + +fn cellsOf(distance: f32) i32 { + return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); +} + +/// Remap track progress into one cell's own window. A stagger delays a glyph +/// without making the effect as a whole end after its last frame. +fn staggered(t: f32, delay: f32) f32 { + if (delay >= 1.0) return t; + return (t - delay) / (1.0 - delay); +} + +test "easing presets have exact endpoints and intended shapes" { + inline for (std.enums.values(Easing)) |easing| { + try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); + try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); + } + try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); + try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); + try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); + try std.testing.expect(sample(.out_back, 0.8) > 1.0); + // Slow at both ends, fast through the middle, and symmetric about the + // halfway point: the same curve the integer byte walk reproduces. + try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); + try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); + try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); + try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > + sample(.linear, 0.6) - sample(.linear, 0.4)); +} + +test "transition progress completes exactly" { + inline for (std.enums.values(Transition)) |effect| { + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); + if (effect.frames() > 0) + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); + try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); + } + try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); + + const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); + try std.testing.expectEqual(@as(f32, 0), shrinking.w); + try std.testing.expectEqual(@as(f32, 0), shrinking.h); + const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); + try std.testing.expect(opening.w > 100); + try std.testing.expect(opening.h > 40); +} + +test "character effects are core-composed and settle on the canonical glyph" { + const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; + const area: CellArea = .of(box); + try std.testing.expectEqual(@as(u16, 4), area.x0); + try std.testing.expectEqual(@as(u16, 2), area.y0); + try std.testing.expectEqual(@as(u16, 20), area.cols); + try std.testing.expectEqual(@as(u16, 6), area.rows); + + inline for (std.enums.values(Transition)) |effect| { + if (comptime !effect.composedByCore()) continue; + // Core composition needs the frozen old grid for every glyph which has + // not arrived, so no character effect may animate without it. + try std.testing.expect(effect.needsPreviousGrid()); + if (comptime effect == .ascii) continue; // owns its own per-cell byte walk + + const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; + const first: Track = .{ .effect = effect, .frame = 0, .to = box }; + var moving = false; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + var col: u16 = 0; + while (col < area.cols) : (col += 1) { + // The last active sample is the exact canonical grid: no cell + // is displaced, churning, or still frozen. + try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); + if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) + moving = true; + } + } + try std.testing.expect(moving); + } +} + +test "each character effect moves glyphs along its own axis" { + const box: Box = .{ .w = 30, .h = 8 }; + const area: CellArea = .of(box); + + // Rows alternate which screen edge they come from, and every glyph in a row + // travels as one rigid slide: one offset, no vertical component. + var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; + const even = charSource(edges, 5, 0, area).at; + const odd = charSource(edges, 5, 1, area).at; + try std.testing.expect(even.cols > 0); + try std.testing.expectEqual(-even.cols, odd.cols); + try std.testing.expectEqual(@as(i32, 0), even.rows); + try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); + edges.frame = 5; + try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); + + // Falling columns are vertical only, staggered, and sample from below the + // destination because the new text is still above the pane. + const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; + var falling = false; + var col: u16 = 0; + while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { + .old => {}, + .byte, .churn => return error.FallShouldNotChurn, + .at => |offset| { + try std.testing.expectEqual(@as(i32, 0), offset.cols); + try std.testing.expect(offset.rows >= 0); + if (offset.rows > 0) falling = true; + }, + }; + try std.testing.expect(falling); + + // The wave displaces rows both ways as it travels, and only rows. + const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; + var above = false; + var below = false; + col = 0; + while (col < area.cols) : (col += 1) { + const offset = charSource(wave, col, 3, area).at; + try std.testing.expectEqual(@as(i32, 0), offset.cols); + if (offset.rows < 0) above = true; + if (offset.rows > 0) below = true; + } + try std.testing.expect(above and below); + + // The curtain has a head: columns behind it hold the old grid, columns the + // head has passed are settled, and the head itself is still sliding. + const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); + try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); + var sliding = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { + .at => |offset| if (offset.cols < 0) { + sliding = true; + }, + .old, .byte, .churn => {}, + }; + try std.testing.expect(sliding); + + var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; + var churning: usize = 0; + var locked: usize = 0; + var changed = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { + .churn => |byte| { + try std.testing.expect(byte >= ' ' and byte <= '~'); + churning += 1; + scramble.frame = 4; + switch (charSource(scramble, col, 0, area)) { + .churn => |next| changed = changed or next != byte, + .old, .at, .byte => {}, + } + scramble.frame = 3; + }, + .at => locked += 1, + .old, .byte => return error.ScrambleShouldNotFreeze, + }; + try std.testing.expect(churning > 0 and locked > 0 and changed); + + // The typewriter writes in reading order with a caret on its head. + const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); + try std.testing.expectEqual( + CharSource{ .old = {} }, + charSource(typewriter, area.cols - 1, area.rows - 1, area), + ); + var carets: usize = 0; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { + .byte => |byte| { + try std.testing.expectEqual(@as(u8, '_'), byte); + carets += 1; + }, + .old, .at, .churn => {}, + }; + } + try std.testing.expectEqual(@as(usize, 1), carets); +} + +test "opening presets separate geometry and content transitions" { + const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; + try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); + try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); + try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); + try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); + try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); + + var track: Track = .{ .effect = .slide, .from = target, .to = target }; + try std.testing.expect(track.active()); + track.frame = track.effect.frames(); + try std.testing.expect(!track.active()); + + track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; + try std.testing.expectEqual(target, track.visualBox()); + + var closing: Track = .{ + .phase = .closing, + .effect = .vertical, + .from = target, + .to = closingBox(.vertical, target), + }; + try std.testing.expectEqual(target, closing.contentBox()); + closing.frame = 2; + try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); +} + +test "dissolve has exact stable endpoints" { + for (0..64) |col| { + const x: u16 = @intCast(col); + try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); + try std.testing.expect(dissolveRevealed(42, x, 7, 1)); + if (dissolveRevealed(42, x, 7, 0.25)) + try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); + } +} + +pub const Animation = struct { + pub const frame_ms: u32 = 16; + pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; + pub const transition_steps: u16 = 10; + + pub fn Transition(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } + + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } + + pub fn retarget(a: *Self, target: Value) void { + a.from = a.displayed; + a.to = target; + a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; + if (a.step == transition_steps) a.displayed = target; + } + + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } + + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; + } + + pub fn Immediate(comptime Value: type) type { + return struct { + const Self = @This(); + + displayed: Value, + + pub fn init(value: Value) Self { + return .{ .displayed = value }; + } + + pub fn isActive(_: *const Self) bool { + return false; + } + + pub fn retarget(a: *Self, target: Value) void { + a.displayed = target; + } + + pub fn advance(_: *Self) void {} + + pub fn snap(a: *Self, value: Value) void { + a.displayed = value; + } + }; + } + + pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); + } + return out; + } + + const TestColor = struct { + rgb: [3]u8, + + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } + }; + + test "Immediate lands where a completed Transition lands" { + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + + var faded = Animation.Transition(TestColor).init(from); + faded.retarget(to); + for (0..transition_steps) |_| faded.advance(); + + var instant = Immediate(TestColor).init(from); + try std.testing.expect(!instant.isActive()); + instant.retarget(to); + try std.testing.expectEqual(faded.displayed, instant.displayed); + + // Never active, so a frontend that renders only while something is animating stops immediately + // rather than spending ten frames discovering there is nothing to draw. + try std.testing.expect(!instant.isActive()); + instant.advance(); + try std.testing.expectEqual(to, instant.displayed); + + instant.snap(from); + try std.testing.expectEqual(from, instant.displayed); + } + + test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Animation.Transition(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); + + var previous = tween.displayed; + for (0..transition_steps) |_| { + tween.advance(); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; + } + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); + } + + test "retarget starts at the currently displayed value" { + const Tween = Animation.Transition(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); + } +}; diff --git a/src/limits.zig b/src/limits.zig deleted file mode 100644 index ee94a4f6..00000000 --- a/src/limits.zig +++ /dev/null @@ -1,237 +0,0 @@ -//! Every board-shaped capacity in one table. -//! -//! These numbers used to be nine `platform == .esp32p4` tests scattered across -//! nine files, each one a separate place to forget. They are not nine -//! decisions: they are ONE decision — how much memory this build is allowed to -//! spend — taken nine times, in nine files, where no reader could see the -//! total. Here the whole budget is on one screen and every cap says what it is -//! measured against. -//! -//! Two booleans derive all of it, and nothing outside this file tests the -//! platform for a capacity again. -//! -//! WHAT DOES NOT BELONG HERE: capability switches. `terminal_panes`, -//! `board_memory.enabled`, `hosted`, `font_picker` and the rest answer "does -//! this build have the thing at all", which is a question about the platform -//! and not about a budget — they stay next to the thing they gate. That -//! division is also why a build option selecting the board's budget on a -//! desktop does not work; the note on `board` below records the attempt. -const std = @import("std"); -const builtin = @import("builtin"); -const config = @import("pardes_config"); - -/// `board` is the ESP32-P4 firmware's budget: a 384 KiB heap and a 240 KiB -/// chunk of L2MEM shared between `.bss`, `.data` and the stack. `reduced` is -/// any freestanding target with no OS under it — the browser's wasm linear -/// memory grown on demand, megabytes rather than tens, but not a desktop's -/// address space. -/// -/// TWO BOOLEANS AND NOT A PROFILE ENUM, and a build option was tried and -/// removed. `-Dmem-profile=board` was meant to let a native test runner -/// compile the board's capacities and boot the core under them; it does not -/// work, and cannot. The dominant term in a boot is `@sizeOf(Pane)`, which -/// carries the ghostty-vt Terminal — 1.1 MiB of it — and what removes that is -/// `pardes.terminal_panes`, a CAPABILITY keyed on the platform rather than a -/// capacity in this table. So the option shrank the rings and left the boot -/// six times over budget, producing a configuration nothing was designed for: -/// `zig build unit-test -Dmem-profile=board` deadlocked in a futex rather than -/// failing, because a hosted build with the board's effect ring silently drops -/// effects a hosted test is waiting on. -/// -/// What DOES test the board's memory pressure natively is in pardes.zig: the -/// grid-scaled cost and the allocation-failure sweep, both of which are -/// platform-independent and run on the ordinary build. See the comment block -/// above `board_heap_bytes` there. -const board = config.platform == .esp32p4; -/// No OS means no address space to reserve megabytes out of, whatever the -/// platform is called. `.web` is wasm32-freestanding and `.esp32p4` is -/// riscv32-freestanding, so the target answers this for both. -const reduced_target = builtin.os.tag == .freestanding; -const KiB = 1024; -const MiB = 1024 * KiB; - -/// THE NUMBER EVERY OTHER NUMBER HERE IS MEASURED AGAINST: the board's whole -/// heap, the 384 KiB chunk of L2MEM at 0x4FF40000 (`05-zig-p4`'s linker script -/// owns the split; the 128 KiB above it measured as L2 cache rather than -/// memory). Unconditional and not profile-derived, because it is a fact about -/// the silicon rather than a budget this build chose — a desktop build that -/// wants to know what the board affords is asking exactly this question, which -/// is what the memory tests in pardes.zig do with it. -pub const board_heap_bytes = 384 * KiB; - -/// How many effects the ring holds. SHRUNK, not moved to the heap, on the -/// board: `pump` drains this to empty on every iteration with an -/// unconditional `while (nextEffect())` — including effects `perform` itself -/// queues — so no capacity can deadlock the drain, and the only question a -/// capacity answers is how big a single-pump BURST may be before `emit` -/// refuses the overflow. The one producer that can burst is `emitWrite`, -/// which chunks arbitrary bytes into 64-byte `.write` effects for a pty, and -/// a build with `terminal_panes == false` has no pty to write to. Everything -/// else queues O(1) effects per event, and `in_q` holds at most 64 events per -/// pump, so 128 leaves two effects per queued event. -/// -/// A 1.0625 MiB inline ring cannot live in the board's 384 KiB heap at all; -/// 128 entries is 34 KiB. NOTE THE BEHAVIOUR CHANGE: `emit` has always -/// refused (not evicted) once full, so on the board a burst larger than 128 effects -/// now drops its tail where 4096 would have held it — reachable only through -/// `emitWrite`, i.e. only if a pty ever appears on this platform. -pub const effect_cap = if (board) 128 else 4096; - -/// Bytes of a pane's pty write that may WAIT in the core when `effect_cap` -/// chunks are already queued. `emitWrite` splits a burst into fixed 64-byte -/// effects, so without this a paste larger than `effect_cap * 64` (256 KiB on -/// a desktop) lost its tail silently — the ring refuses rather than evicts, -/// which keeps queued bytes in order but cut the new ones off. The remainder -/// parks here instead and `nextEffect` refills the ring as the host drains it, -/// so a large paste is DELAYED rather than truncated. -/// -/// 4 MiB matches `tty.max_paste_bytes`, the largest burst a host can hand the -/// core in one event, so the bound is the one the producer already enforces. -/// Zero on the board: no ptys means no `emitWrite`, and the allocation this -/// would justify cannot live in 384 KiB anyway. A zero cap parks nothing and -/// restores the old refusal exactly. -pub const pending_write_cap: usize = if (board) 0 else 4 << 20; - -/// Rows the per-pane soft-wrap map covers. `wrapWidth` refuses to wrap a pane -/// taller than this (it reads the array's own length), so shrinking it cannot -/// truncate a map — a taller pane renders unwrapped, exactly as documented on -/// `Pane.wrap_line`. A serial console is not 128 rows tall. -pub const wrap_rows = if (board) 128 else 256; - -/// A shell's reported working directory, owned inline by the pane. Zero-sized -/// where there are no processes to report one: the `PdfSlot` rule, applied to -/// a capacity whose sole producer (`Pardes.setCwd`, fed by a pty's prompt -/// report) does not exist without terminal panes. `setOwnedCwd` clamps, so a -/// zero cap reads as "no directory known" — which is the truth here. -/// -/// Keyed on the profile rather than on `pardes.terminal_panes`, which this -/// file must not import (the core imports the table, not the other way round). -/// The two agree by construction: the board is the only build with no ptys. -pub const cwd_buf_cap = if (board) 0 else 1024; - -/// EDIT BOUNDARIES REMEMBERED PER FILE PANE. Every entry owns a gpa copy of -/// the WHOLE file, so this number multiplies heap, not just the pane: 256 of -/// them is not a bound a 384 KiB board could ever reach anyway. `pushHistory` -/// evicts and frees the oldest once full, so the smaller ring loses the -/// deepest undo steps and nothing else — no truncation, no dropped edit. -pub const undo_max = if (board) 16 else 256; - -/// How many message-row lines the session keeps for `Messages`, and one of the -/// bigger fixed costs on `Pardes`: an entry is 262 bytes, so 128 of them is -/// 32.75 KiB that is allocated whether or not anybody ever reads it. That is -/// 8.5% of the board's whole 384 KiB heap and about the size of its effect -/// ring, so the board takes sixteen — enough that a failure you looked away -/// from is still there, which is the whole point, and not enough to matter -/// beside the panes. This belongs here rather than in config.zig for exactly -/// the reason the file's header gives: it is a board-shaped capacity. -pub const message_log = if (board) 16 else 128; - -/// Bounds the only user-editable, schema-owned tag fragment. It IS the storage -/// bound: `Pane.tag_tail` is `[max_tag_tail]u8`, and every writer (appendTag, -/// tagInsert, restoreDumpTail, the acmefs `tag` file) refuses input that does -/// not fit rather than truncating it, so the schema limit and the buffer can -/// never disagree — a dump reader can reject data before copying it into a -/// pane. -/// -/// 512 on the P4 firmware. A tag is ONE line — a pane's path plus its command -/// words — and 4 KiB of it is 4 KiB per pane out of a 384 KiB heap. A serial -/// console is 80 columns; 512 is six of those. -pub const max_tag_tail: usize = if (board) 512 else 4096; - -/// HOW LONG A HOST-SUPPLIED ABSOLUTE PATH MAY BE, and the only reason that -/// record was ever kilobytes: the shell a native host resolved, the font file -/// a native picker returned, and (in pardes.zig) the one watched theme file. -/// All three name something on a FILESYSTEM, and all three are retained -/// inline because the core has no allocator at the point they arrive. -/// -/// Fixed at 4095 wherever a filesystem exists — deliberately NOT derived from -/// std.fs PATH_MAX, which web has no answer for, and 4095 rather than 4096 so -/// the macOS C bridge's NUL fits without a second, subtly different limit at -/// that boundary. Zero on the P4 firmware, which has no filesystem, no -/// processes to spawn a shell for and no font picker: `Text(0)` is a -/// zero-sized field whose `set` refuses every non-empty path, so the three -/// producers report failure instead of storing 12 KiB nothing can fill. -pub const host_path_cap: usize = if (board) 0 else 4095; - -/// WHETHER PARDES'S OWN SOURCE IS EMBEDDED — the source_manifest allowlist, -/// which is a capacity spelled as rodata rather than as a number. -/// -/// ON THE P4 the allowlist is EMPTY, and that is the whole difference: the -/// table is ~0.95 MiB of rodata against a 1.5 MiB flash partition, and the -/// firmware's filesystem is the serial host's, reached through the Host -/// vtable. The API is unchanged — `all` is a zero-length array and `find` -/// answers null — so every caller compiles identically and simply finds -/// nothing embedded. -pub const embedded_sources = !board; - -/// Bytes per dumped row, and it is a different number on the board. -/// -/// `hexdump -C`'s sixteen is the layout everyone can already read, and it needs 79 columns: ten for -/// the address, forty-eight for the hex, a gap, and the eighteen-column ASCII gutter. The P4 drives -/// a 56-column grid of which seven go to the line-number gutter, so a sixteen-byte row wraps onto a -/// second display line and the columns stop lining up - which is the entire value of the layout. -/// -/// Eight fits in 46 and keeps every property that matters: address on the left, fixed-width hex -/// columns, ASCII on the right, and a gap at the halfway mark because the eye counts in fours and -/// eights rather than in sixteens. -/// -/// NO `0x` ON WHAT THESE WORDS PRINT, which is where two of those columns came from. It reads no -/// worse - every number here is hex, there is no other kind, and the words refuse a decimal one - and -/// it buys something better than the width: an address in a dump can now be typed straight back into -/// a `Peek` without editing it, because bare hex is exactly what the parser wants. Output that is -/// valid input is worth more than a prefix restating what the whole file already says. -pub const hexdump_row_bytes: u32 = if (board) 8 else 16; - -/// Three tiers, because the address space differs by four orders of magnitude. -/// `reduced_target` is the browser: a wasm linear memory it grows on demand, so -/// the static reservations are megabytes rather than tens. -/// -/// `board` is ESP32-P4 firmware, and its tier is deliberately ALL FALLBACK. Every -/// capacity here is a `StackFallbackAllocator`'s buffer, which is a static and -/// therefore lands in `.bss` — and on the P4 `.bss`, `.data` and the stack all -/// share ONE 240 KiB chunk of L2MEM at 0x4FF03000, while the heap the fallback -/// allocator hands out is the separate 384 KiB chunk at 0x4FF40000 - the 128 KiB -/// above that measured as L2 cache rather than memory. A -/// megabyte-shaped reservation here would not fit, and every byte that did fit -/// would be taken from the stack's neighbourhood to duplicate memory the heap -/// already has. So the buffers exist only because the type requires one: 4 KiB -/// absorbs the small churn, and everything else spills to the real heap on the -/// first allocation. -pub const arena = struct { - pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB; - pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB; - // Zero is legal and always spills, which is exactly what an arena for a - // compiled-out subsystem should do. `StackFallbackAllocator(0).buffer` is - // `[0]u8`; `get()` inits the FixedBufferAllocator over an empty slice, so - // `FixedBufferAllocator.alloc` fails every nonzero request and `alloc` - // falls through to `self.fallback_allocator.rawAlloc`, while `ownsPtr` over - // an empty range is false for every pointer so `resize`/`remap`/`free` - // route to the fallback too. See lib/std/heap.zig, StackFallbackAllocator. - pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB; - pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB; - pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB; -}; - -// THE REGRESSION GUARD for the refactor that created this file: nine caps -// moved out of nine files, and the one thing that must not have changed is -// what a tty/gui/macos build gets. Spelling the historical desktop numbers -// here as literals is the point — a derivation would agree with itself. -test "board limits: a desktop build keeps exactly its historical capacities" { - if (board or reduced_target) return error.SkipZigTest; - try std.testing.expectEqual(4096, effect_cap); - try std.testing.expectEqual(256, wrap_rows); - try std.testing.expectEqual(1024, cwd_buf_cap); - try std.testing.expectEqual(256, undo_max); - try std.testing.expectEqual(@as(usize, 4096), max_tag_tail); - try std.testing.expectEqual(@as(usize, 4095), host_path_cap); - try std.testing.expect(embedded_sources); - try std.testing.expectEqual(@as(u32, 16), hexdump_row_bytes); - // The arena tier a desktop gets is the third one, so it is only the - // historical desktop tier when the target is not itself reduced. - if (reduced_target) return; - try std.testing.expectEqual(32 * MiB, arena.pardes); - try std.testing.expectEqual(16 * MiB, arena.frame); - try std.testing.expectEqual(16 * MiB, arena.tree_sitter); - try std.testing.expectEqual(32 * MiB, arena.image); - try std.testing.expectEqual(if (config.mupdf) 64 * MiB else 64 * KiB, arena.pdf); -} diff --git a/src/look.zig b/src/look.zig index 236b6950..daea5221 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1,45 +1,22 @@ -//! What a click on text MEANS. The acme "look" (right click / Enter): expand -//! the click to a file-ish word, then resolve it against the pane's directory. -//! How a word is SPELLED — the isfilec set, the `:LINE:COL` suffix, `@pN`, the -//! URL schemes, the image extensions — is config.zig; this file is only what -//! the spelling RESOLVES to. -//! -//! This is the one deliberately platform-divergent file — the divergence is a -//! comptime switch on pardes.platform, used the way the stdlib switches on -//! os.tag, so every platform's behavior sits in the same screenful: -//! tty/gui — the word resolves through the real filesystem (realpath, -//! open(O_DIRECTORY)); dirs open shells, files open file panes. -//! web — tracked Pardes .zig sources form a build-generated read-only -//! filesystem; URLs still open in a new tab. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); -const lsp = @import("lsp/lsp.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; -/// The virtual filesystem, on every platform: the browser has only this, and -/// `run-isolated` chooses it (see `isolated` below). -const embedded_sources = @import("source_manifest.zig"); +const fs = @import("fs.zig"); +const platform_has_fs = fs.platform_has_fs; -extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; extern "c" fn fork() c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -// absolute opener path per OS: execv must not search PATH (no allocation -// between fork and exec), same rule as the shell spawn. -// ponytail: hardcoded path; a distro that puts xdg-open elsewhere (nix) needs -// a PATH search in the child, which is not fork-safe here. const opener_path: ?[*:0]const u8 = switch (builtin.os.tag) { .linux => "/usr/bin/xdg-open", .macos => "/usr/bin/open", else => null, }; -/// Hand a URL to the desktop — the native half of the web backend's -/// window.open. Double fork: the opener is reparented to init, so the one -/// child we DO wait for exits immediately and nothing is left to reap. pub fn openLink(url: []const u8) void { const opener = opener_path orelse return; var buf: [1024]u8 = undefined; @@ -56,59 +33,20 @@ pub fn openLink(url: []const u8) void { _ = libc.waitpid(pid, null, 0); } -/// WHERE in a pane a look word points. A spot (`:LINE:COL`) — or a SPAN, when -/// the word carries a range (config.range_sep), which a look SELECTS instead -/// of merely parking on. Everything is 1-based and 0 means absent, so a bare -/// path is the all-zero Spot and `end_line == 0` is the question "is this a -/// range". pub const Spot = struct { line: usize = 0, col: usize = 0, end_line: usize = 0, - /// 0 with a live `end_line` is the whole-lines form: through the END of - /// end_line, newline included, which is what helix's `x` selects. end_col: usize = 0, }; -/// digits at `i` and where they end; `end == i` means there were none. Four -/// numbers now come out of the same token, and spelling the scan four times -/// is how one of them ends up subtly different from the others. fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { var v: usize = 0; var j = i; - // SATURATING, and this is not defensive programming — it is the fix for a - // crash on an ordinary keystroke. The digits come off whatever word is - // under the pointer, so `*` and `+` here run on text the user never wrote - // and cannot control: a right-click, an Enter, or an `n` on anything shaped - // `foo:99999999999999999999` — a hash in a log, a column of a CSV, the - // output of any program — overflowed a `usize` and took the editor down - // with "integer overflow". A number too big to be a line is not a line, and - // `maxInt` is refused by every consumer for free: `file_pane.open` asks - // `line <= total` and `focusPaneLine` asks `id < MAX_PANES`. Same shape - // acmefs.zig's address parser already uses. while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) v = v *| 10 +| (tok[j] - '0'); return .{ .v = v, .end = j }; } -/// peel a trailing :LINE[:COL] spot, or one of the three range spellings, off -/// a look word (config.line_col_sep / config.range_sep own both characters): -/// main.zig:100 -> line 100 -/// main.zig:100:7 -> line 100, col 7 -/// main.zig:100: -> line 100 grep -n's trailing delimiter -/// main.zig:100-104 -> lines 100..104 whole -/// main.zig:100:7-21 -> line 100, cols 7..21 -/// main.zig:100:7-104:3 -> line 100 col 7 .. line 104 col 3 -/// -/// A tail that does not parse leaves the token a plain PATH, which is the rule -/// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the -/// last one goes back to hunting for a later ':' and finds none), because a -/// range needs a number on both sides of its dash. -/// -/// `end` is how far into `tok` the form actually REACHED. The read is lenient -/// by design — `main.zig:100:7x` is the file at line 100 and the mangled `:7x` -/// is simply dropped — so `end == tok.len` is the separate question "is the -/// whole token this target and nothing else", which is what a row-grained step -/// must ask before it selects a run of a line (lookableLineSpan). pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { @@ -117,7 +55,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: if (l.end == sep + 1) continue; // no digits after ':' const path = tok[0..sep]; var i = l.end; - // `:LINE-ENDLINE`: whole lines, no column anywhere in the form if (i < tok.len and tok[i] == config.range_sep) { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range @@ -127,10 +64,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; if (i == tok.len) return .{ .path = path, .at = at, .end = i }; - // `:COL`. A column that does not parse is dropped and the LINE still - // stands, which is how this has always read a half-mangled suffix — and - // `end` stops at the last character that DID read, so the caller that - // cares can tell the two apart. const c = num(tok, i + 1); if (c.end == i + 1) return .{ .path = path, .at = at, .end = i }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) @@ -138,9 +71,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: at.col = c.v; i = c.end; if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i }; - // `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that - // first number was the end LINE all along. One lookahead, and it is - // what lets the two-number and four-number forms share a spelling. const e = num(tok, i + 1); if (e.end == i + 1) return .{ .path = path, .at = at, .end = i }; at.end_line = at.line; @@ -168,13 +98,11 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { .{ .tok = "main.zig:100-104", .path = "main.zig", .at = .{ .line = 100, .end_line = 104 } }, .{ .tok = "main.zig:100:7-21", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 100, .end_col = 21 } }, .{ .tok = "main.zig:100:7-104:3", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 104, .end_col = 3 } }, - // the dash cases that must stay ORDINARY PATHS .{ .tok = "my-file.zig", .path = "my-file.zig", .at = .{} }, .{ .tok = "my-file:10", .path = "my-file", .at = .{ .line = 10 } }, .{ .tok = "x:1-y", .path = "x:1-y", .at = .{} }, .{ .tok = "a-b-c", .path = "a-b-c", .at = .{} }, .{ .tok = "2026-07-30", .path = "2026-07-30", .at = .{} }, - // a mangled tail still yields what parsed (unchanged behaviour) .{ .tok = "main.zig:100x", .path = "main.zig:100x", .at = .{} }, .{ .tok = "main.zig:100:7x", .path = "main.zig", .at = .{ .line = 100 } }, }; @@ -186,11 +114,6 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { } test "a number too big to be a line saturates instead of taking the editor down" { - // These are keystrokes, not arguments. `parsePathLine` runs on whatever - // word is under the pointer on a right-click, an Enter or an `n` — so the - // digits come out of a hash in a log, a CSV column, or any program's - // output, and an unchecked `v * 10` there is a panic on ordinary use. Every - // number in the token comes through the same scan, so all four are tried. const huge = "99999999999999999999999999"; const cases = [_][]const u8{ "f.zig:" ++ huge, @@ -201,31 +124,21 @@ test "a number too big to be a line saturates instead of taking the editor down" for (cases) |tok| { const got = parsePathLine(tok); try std.testing.expectEqualStrings("f.zig", got.path); - // Saturated rather than wrapped: a wrap would address a REAL line, and - // silently jumping somewhere is worse than not jumping. try std.testing.expect(got.at.line >= 1); } - // ...and the pane address, which has its own scan. `focusPaneLine` refuses - // anything past MAX_PANES, so this resolves to a pane that cannot exist. var realbuf: [4096]u8 = undefined; - const target = resolve("@p" ++ huge, "/tmp", &realbuf); + const target = resolve(null, "@p" ++ huge, "/tmp", &realbuf); try std.testing.expect(target == .pane); try std.testing.expect(target.pane.id >= 16); } test "parsePathLine: `end` separates a whole-token target from a lenient read" { - // the whole token IS the target: every spelling the doc above lists for ([_][]const u8{ "main.zig", "main.zig:100", "main.zig:100:7", "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3", "@p3:10:5", "x:1-y", }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end); - // ...and the reads that DROP a tail: a result row with its matched text - // still attached, which is exactly what a row-grained step must not select - // whole (lookableLineSpan). Note where each one STOPS — a spot is only - // taken once its whole form has read, so the `:7` of a `:100:7 text` row - // is dropped along with the text and `end` says so. const partial = [_]struct { tok: []const u8, end: usize }{ .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len }, @@ -234,17 +147,11 @@ test "parsePathLine: `end` separates a whole-token target from a lenient read" { .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len }, }; for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end); - // A form that breaks off mid-range is not a lenient read at all: the scan - // goes back for a later ':', finds none, and the token is a plain PATH - // whole — which resolves or does not on its own merits. const whole = "main.zig:100-104 whole lines"; try std.testing.expectEqual(whole.len, parsePathLine(whole).end); try std.testing.expectEqualStrings(whole, parsePathLine(whole).path); } -/// A file-like Look target has a rendering kind only in MuPDF builds. The -/// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable -/// from any other ordinary file before it reaches the core. pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; pub const FileTarget = struct { @@ -259,9 +166,6 @@ pub const Target = union(enum) { file: FileTarget, image: struct { path: []const u8 }, url: []const u8, - /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one - /// target that names a live pane instead of a path, because terminals and - /// output buffers have no file for a location to point at. pane: struct { id: usize, at: Spot }, }; @@ -290,7 +194,7 @@ test "PDF file kinds exist only in MuPDF-enabled builds" { test ".pdf Look paths are ordinary files when MuPDF is disabled" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - const target = resolve("docs/design.pdf", ".", &realbuf); + const target = resolve(null, "docs/design.pdf", ".", &realbuf); switch (target) { .file => |file| { if (comptime pdf_enabled) @@ -302,110 +206,48 @@ test ".pdf Look paths are ordinary files when MuPDF is disabled" { } } -/// Where a look-able word actually SITS inside a run of non-whitespace. pub const Span = struct { start: usize, end: usize }; -/// The punctuation a path wears in prose and never owns. Two sets, because -/// the two ends are not alike: a directory may legally END in `/`, and the -/// `:` that closes `grep -n`'s `main.zig:100:` is junk on the right and -/// meaningful nowhere on the left. const lead_trim = "([{<\"'`*"; const trail_trim = ")]}>\"'`*,;:.!?"; -/// The largest look-able span inside one whitespace-delimited `word`, or null -/// when nothing in it resolves. This is the WORD grain of n/N — split a row on -/// whitespace and take the biggest piece of each run Look can act on — which -/// is what a terminal, a file and a PDF step, because their lines are free -/// text and a line may hold several places (an `ls` row hops file to file). -/// A results buffer steps ROWS instead: lookableLineSpan. -/// -/// TWO resolve attempts at most, which is what keeps a motion across a -/// screenful of prose from being a hundred realpaths: the run with every -/// wrapper character peeled off BOTH ends at once, then — only if that found -/// nothing — the run exactly as written. -/// -/// PEELED FIRST, which is the ordering that matters. `resolve` is lenient -/// about a tail it cannot parse (`main.zig:12:3,` yields the FILE and drops -/// the position, by design), so asking it about the raw run first would -/// happily answer yes and swallow the comma along with the `:3`. Peeling -/// first hands it `main.zig:12:3` and the look lands on the column. The raw -/// run stays as the fallback for the file genuinely named `foo,` or `..`, -/// where the peel eats something real. -/// -/// Deliberately NOT a search for the longest resolving substring: that costs -/// a syscall per prefix to find a path hiding inside a word nobody typed as -/// one. A run needing a cleverer peel is still one Enter away with the cursor -/// parked on it. -/// -/// Direction-free on purpose: n and N ask this the same question about the -/// same run and get the same span back, which is what lets the two motions be -/// exact inverses of each other. -pub fn lookableSpan(word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { +pub fn wordSpan(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { if (word.len == 0) return null; var lo: usize = 0; var hi: usize = word.len; while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1; while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1; - if (lo < hi and resolve(word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; - // nothing came off, so the peeled attempt WAS the raw one + if (lo < hi and resolve(p, word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi }; if (lo == 0 and hi == word.len) return null; - if (resolve(word, cwd, realbuf) == .none) return null; + if (resolve(p, word, cwd, realbuf) == .none) return null; return .{ .start = 0, .end = word.len }; } test "lookableSpan peels prose punctuation off a path, largest first" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // the bare run resolves whole, wrappers and all left alone try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig".len }), - lookableSpan("src/look.zig", ".", &realbuf), + wordSpan(null, "src/look.zig", ".", &realbuf), ); - // ...and a wrapped one gives back the span INSIDE the wrappers try std.testing.expectEqualDeep( @as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }), - lookableSpan("(src/look.zig),", ".", &realbuf), + wordSpan(null, "(src/look.zig),", ".", &realbuf), ); - // the `:LINE:COL` tail is part of the span: it is what a look READS try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }), - lookableSpan("src/look.zig:12:3,", ".", &realbuf), + wordSpan(null, "src/look.zig:12:3,", ".", &realbuf), ); - // grep -n's trailing delimiter comes off, the line number stays try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }), - lookableSpan("src/look.zig:12:", ".", &realbuf), + wordSpan(null, "src/look.zig:12:", ".", &realbuf), ); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("nothing-here", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "nothing-here", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "((()))", ".", &realbuf)); } -/// The largest look-able span ANCHORED at the start of `line`'s text, or null -/// when the row names no place at all. This is the ROW grain of n/N, and what -/// a results buffer steps: a row there IS one location — `path:LINE:COL text` -/// — and the words after the location are the MATCH, not a second place to -/// step to. One stop per row, always its head. -/// -/// LARGEST, so the candidates are the run from the first non-blank cell out to -/// each whitespace boundary, tried LONGEST first: a path with a blank in it -/// (`old notes/plan.txt`) beats the word hiding inside it, which is the case -/// the word grain cannot express at all. -/// -/// A candidate only counts when it is the target EXACTLY — parsePathLine -/// consuming every byte of it, after the same wrapper peel lookableSpan does. -/// That gate is what keeps longest-first from swallowing the whole row: -/// `resolve` is lenient by design and answers `src/x.zig:12:5 const y` with -/// the FILE, so without it every result row would select out to its right -/// margin and throw the `:5` away along with the text. A url is lenient the -/// same way in the other direction — it is recognised by its PREFIX, so a -/// longer run is not a longer link — and only the filesystem can vouch for a -/// span with a blank inside it, so only the filesystem is allowed to. -/// -/// Cost is the word grain's: the exactness gate is pure parsing, so a row -/// spends at most one resolve per whitespace boundary and the ordinary result -/// row — whose head is its whole location — spends two. -pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { +pub fn lineSpan(p: ?*pardes.Pardes, line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { var lo: usize = 0; while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1; var hi = std.mem.trimEnd(u8, line, " \t\r").len; @@ -415,13 +257,12 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ? while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1; while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1; const cand = line[a..b]; - if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(cand, cwd, realbuf)) { + if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(p, cand, cwd, realbuf)) { .dir, .file, .image => return .{ .start = a, .end = b }, .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null) return .{ .start = a, .end = b }, .none => {}, }; - // ...else the same run one word shorter while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1; while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1; } @@ -431,1302 +272,81 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ? test "lookableLineSpan takes the row's location and stops before its text" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // a grep row: the location, and NOT the matched code after it — which - // `resolve` would happily answer for, minus the column try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }), - lookableLineSpan("src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), + lineSpan(null, "src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), ); - // an lsp/jumplist row, whose column is followed by a blank rather than a - // ':' — the form a lenient read drops on the floor try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }), - lookableLineSpan("src/look.zig:12:5 pub fn resolve", ".", &realbuf), + lineSpan(null, "src/look.zig:12:5 pub fn resolve", ".", &realbuf), ); try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }), - lookableLineSpan("@p3:10:5 /home/goblin", ".", &realbuf), + lineSpan(null, "@p3:10:5 /home/goblin", ".", &realbuf), ); - // a bare path row, wrappers peeled and blank indent skipped like anywhere - // else — the anchor is the row's first non-blank cell, not column zero try std.testing.expectEqualDeep( @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }), - lookableLineSpan(" (src/look.zig)", ".", &realbuf), + lineSpan(null, " (src/look.zig)", ".", &realbuf), ); - // a link row keeps its link and leaves the title alone: a longer run is - // not a longer url try std.testing.expectEqualDeep( @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }), - lookableLineSpan("https://pardes.dev/a Chapter One", ".", &realbuf), + lineSpan(null, "https://pardes.dev/a Chapter One", ".", &realbuf), ); - // ANCHORED: a place mentioned mid-row is not a stop, and a row with no - // place at its head is no stop at all try std.testing.expectEqual( @as(?Span, null), - lookableLineSpan("see also src/look.zig", ".", &realbuf), + lineSpan(null, "see also src/look.zig", ".", &realbuf), ); - try std.testing.expectEqual(@as(?Span, null), lookableLineSpan(" ", ".", &realbuf)); - try std.testing.expectEqual(@as(?Span, null), lookableLineSpan("", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lineSpan(null, " ", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lineSpan(null, "", ".", &realbuf)); } test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; - // A real path with a blank in it, under a directory whose own name is the - // first word of the row: the word grain can only ever see `tmp`, and the - // row grain sees the file, because it asks about the longest run first. - const io = std.Io.Threaded.global_single_threaded.io(); - var tmp = try std.Io.Dir.cwd().openDir(io, "/tmp", .{}); - defer tmp.close(io); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); const name = "pardes look span.txt"; - try tmp.writeFile(io, .{ .sub_path = name, .data = "" }); - defer tmp.deleteFile(io, name) catch {}; + try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" }); + try tmp.dir.createDir(io, "subdir", .default_dir); + var path: [4096]u8 = undefined; + const len = try tmp.dir.realPathFile(io, name, &path); + const cwd = std.fs.path.dirname(path[0..len]).?; try std.testing.expectEqualDeep( - @as(?Span, .{ .start = 0, .end = ("tmp/" ++ name).len }), - lookableLineSpan("tmp/" ++ name, "/", &realbuf), + @as(?Span, .{ .start = 0, .end = name.len }), + lineSpan(null, name, cwd, &realbuf), ); - // ...and the shrink still finds the shorter run when the long one is - // prose. Candidates END at a blank, so the runs tried are whole words: - // there is no hunt for a path hiding inside one (lookableSpan's rule). try std.testing.expectEqualDeep( - @as(?Span, .{ .start = 0, .end = "tmp".len }), - lookableLineSpan("tmp holds pardes look span.txt", "/", &realbuf), + @as(?Span, .{ .start = 0, .end = "subdir".len }), + lineSpan(null, "subdir holds pardes look span.txt", cwd, &realbuf), ); } -/// Resolve a looked-at word against the pane's directory. `realbuf` must -/// outlive the returned Target (native paths point into it; web paths are -/// process-lifetime slices in the embedded source archive). -pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { +pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); const word = pl.path; if (word.len == 0) return .none; - // `@pN` addresses a pane, not a path: every platform, before the fs. if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) { var id: usize = 0; for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; - // Saturating for the same reason `num` above is: this scan also - // runs on a word somebody merely clicked. `focusPaneLine` refuses - // anything past `MAX_PANES`, so a saturated id addresses nothing. id = id *| 10 +| (c - '0'); } else return .{ .pane = .{ .id = id, .at = pl.at } }; } - // a URL is a URL everywhere: no filesystem can answer it, so it leaves the - // app (browser tab on web, xdg-open/open on the desktop). for (config.url_schemes) |scheme| { if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed }; } - if (platform_has_fs) { - var joinbuf: [2048]u8 = undefined; - const joined: ?[:0]u8 = if (word[0] == '/') - (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null) - else - (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null); - const jz = joined orelse return .none; - const rp = realpath(jz.ptr, realbuf) orelse return .none; - const resolved = std.mem.span(rp); - if (isDir(rp)) return .{ .dir = resolved }; - if (comptime pdf_enabled) if (isPdfPath(resolved)) return .{ .file = .{ - .path = resolved, - .at = pl.at, - .kind = .pdf, - } }; - if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; - return .{ .file = .{ .path = resolved, .at = pl.at } }; - } else { - // web: tracked Zig sources resolve inside the build-generated, - // read-only source filesystem. - if (resolveEmbedded(word, cwd, realbuf)) |source| - return .{ .file = .{ .path = source.path, .at = pl.at } }; - return .none; - } -} - -/// Resolve a source path without teaching the core about a browser filesystem. -/// Cwd-relative and absolute dump paths are normalized, with printed archive -/// paths also accepted root-relative. The suffix match lets a dump made in -/// `/host/repo` address names that deliberately remain relative to the root. -fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source { - var wordbuf: [4096]u8 = undefined; - const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; - if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); - - var joined: [4096]u8 = undefined; - if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| - if (normalizeVirtualPath(candidate, scratch)) |normalized| - if (findEmbeddedSource(normalized, true)) |source| return source; - // A printed archive path is root-relative even when its surrounding dump - // pane came from some unrelated cwd. - return findEmbeddedSource(normalized_word, false); -} - -fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { - var len: usize = 0; - var parts = std.mem.tokenizeAny(u8, path, "/\\"); - while (parts.next()) |part| { - if (std.mem.eql(u8, part, ".")) continue; - if (std.mem.eql(u8, part, "..")) { - while (len > 0 and out[len - 1] != '/') len -= 1; - if (len > 0) len -= 1; - continue; - } - const extra = part.len + @intFromBool(len != 0); - if (len + extra > out.len) return null; - if (len != 0) { - out[len] = '/'; - len += 1; - } - @memcpy(out[len..][0..part.len], part); - len += part.len; - } - if (len == 0) return null; - return out[0..len]; -} - -fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source { - for (embedded_sources.all) |source| - if (std.mem.eql(u8, source.path, path)) return source; - if (!allow_root_suffix) return null; - for (embedded_sources.all) |source| { - if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; - if (std.mem.endsWith(u8, path, source.path)) return source; - } - return null; -} - -/// Whether there is a real filesystem to reach at all. An ISOLATED build has -/// none by construction — the option is comptime, so every libc path below is -/// dead code the compiler removes rather than a branch that could be taken by -/// accident. The browser has never had one either, and both then read the same -/// embedded source. -const platform_has_fs = !pardes.isolated and switch (pardes.platform) { - .tty, .gui, .macos => true, - // The browser's filesystem is the embedded source archive; the P4 - // firmware's is whatever the serial host answers for, through the Host - // vtable — never a path this process opens. - .web, .esp32p4 => false, -}; - -// Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the -// keystroke that asked for it — so the walk must end whatever it is pointed at. -// Three caps, because each alone leaks: hits bound the results buffer, depth -// bounds a deep tree, and steps bound a wide shallow one (a pattern that never -// matches would otherwise walk the whole disk without ever filling `hits`). -const find_max_hits = 512; -const find_max_depth = 16; -const find_max_steps = 100_000; -/// One search result buffer. A grep can visit one root per pane, each root can -/// contribute `find_max_hits`, and native paths are capped at 4096 bytes below. -/// Callers allocate this conservative ceiling once; a full buffer truncates at -/// the last complete row. -pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); - -/// Directories a source tree has no answers in, skipped whole. fd reads -/// .gitignore for this; pardes has no ignore parser, and every one of these -/// costs a real search: agave's `target/` alone is 456_000 of its 460_000 -/// entries and holds 1_200 of the 1_242 paths matching "bank", so a Find for -/// `bank` burned the whole 512-hit budget on build artifacts and never -/// reached `runtime/src/bank.rs`. That looked like a broken matcher. -const find_skip = [_][]const u8{ - ".git", ".jj", "target", "node_modules", - ".venv", "__pycache__", ".zig-cache", "zig-out", -}; - -/// `fd`, in-core: every path under `dir` whose NAME contains `pat` (plain -/// case-insensitive substring — fd's default is a regex and pardes has no -/// regex engine to spend on one), one path per line into `out`, RELATIVE to -/// `dir` — the results buffer is itself named `dir/+Search`, so every row -/// resolves against the same directory the walk started in and reads as the -/// short name the searcher was looking for. Only real directories are -/// entered, so a symlink can never close a cycle. -/// Filesystem setup and traversal errors are returned to the UI boundary. -pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize { - var hits: [find_max_hits][]const u8 = undefined; - var hits_len: usize = 0; - if (platform_has_fs) { - // Zig 0.16 moved the filesystem behind std.Io; the blocking - // single-threaded implementation (the one std.debug itself holds) IS - // the synchronous walk the core uses — no pool, no cancelation. - const io = std.Io.Threaded.global_single_threaded.io(); - var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); - defer root.close(io); - // walkSelectively, not walk: descending is opt-in, which is the only - // way to express the depth cap and find_skip at all. - var w = try root.walkSelectively(arena); - defer w.deinit(); - var steps: usize = 0; - walk: while (steps < find_max_steps and hits_len < hits.len) { - steps += 1; // an unreadable dir burns a step too, so it cannot spin - const e = (try w.next(io)) orelse break; - if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { - // e.path points into the walker's own buffer, dead at next() - hits[hits_len] = try arena.dupe(u8, e.path); - hits_len += 1; - } - if (e.kind != .directory or e.depth() >= find_max_depth) continue; - for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; - try w.enter(io, e); - } - } else { - // web: the build-generated source archive IS the filesystem, and it is - // already a flat list of paths — the whole walk is the match. - for (embedded_sources.all) |s| { - if (hits_len >= hits.len) break; - if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) { - hits[hits_len] = s.path; - hits_len += 1; - } - } - } - // readdir order is undefined; sort so the same tree gives the same buffer - // twice running and n/N walks it in a sane order. - std.mem.sort([]const u8, hits[0..hits_len], {}, struct { - fn lt(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); - } - }.lt); - var written: usize = 0; - for (hits[0..hits_len]) |h| { - if (h.len + 1 > out.len - written) break; - @memcpy(out[written..][0..h.len], h); - written += h.len; - out[written] = '\n'; - written += 1; - } - return written; -} - -/// how much of one file Grep reads. The core is synchronous, so a tree with a -/// core dump in it must not stall the keystroke: past this the tail of the file -/// is simply not searched (`grep -R` would read it all). -const grep_max_bytes = 256 * 1024; -const grep_max_files = 20_000; - -/// every line of `text` holding `pat`, as `path:LINE:COL-ENDCOL text` rows — -/// the shared half of grep(), and the shape every result row in pardes has: -/// the leading word is a look target, so n/N walk the hits. The row names the -/// MATCH's span and not just its first cell, so stepping onto one selects the -/// text that matched (config.range_sep). Returns the rows written, at most -/// `budget`. -const GrepResult = struct { bytes: usize, hits: usize }; - -fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult { - var result: GrepResult = .{ .bytes = 0, .hits = 0 }; - var line: usize = 0; - var it = std.mem.splitScalar(u8, text, '\n'); - while (it.next()) |raw| { - line += 1; - if (result.hits >= budget) break; - const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; - // one minified line can be the whole file: cut it, but never mid - // codepoint — a partial UTF-8 sequence reaches the renderer as a hit - // row and there is nothing sane for it to draw. - const ln = std.mem.trimEnd(u8, raw, " \t\r"); - var cut = @min(ln.len, 200); - while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; - const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{ - path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut], - }) catch break; - result.bytes += row.len; - result.hits += 1; - } - return result; -} - -/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat` -/// (plain case-insensitive substring, like every other search here), one row -/// per hit into `out`. A row's path is RELATIVE to `base` — the directory of -/// the pane that asked, which is also the one its results buffer is named in, -/// so a row reads as the short name that pane would have typed and still looks -/// up. A hit `base` does not contain (another pane's tree) keeps its absolute -/// path, which resolves from anywhere. Same walk, same skip list and same three -/// caps as find(), plus grep_max_bytes and a NUL sniff so a binary never lands -/// in the results. -/// Filesystem setup, traversal, and read errors are returned to the UI boundary. -pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize { - var hits: usize = 0; - var written: usize = 0; - if (!platform_has_fs) { - // web: the build-generated source archive IS the filesystem - for (embedded_sources.all) |s| { - if (hits >= find_max_hits or written == out.len) break; - const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits); - hits += result.hits; - written += result.bytes; - } - return written; - } - const root_path = std.mem.trimEnd(u8, dir, "/"); - const home = std.mem.trimEnd(u8, base, "/"); - // The walk collects into one bounded allocation, then the read scans in - // sorted order. e.path dies at the next next(), so these are copies. - const files = try arena.alloc([]const u8, grep_max_files); - var files_len: usize = 0; - { - const io = std.Io.Threaded.global_single_threaded.io(); - var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); - defer root.close(io); - var w = try root.walkSelectively(arena); - defer w.deinit(); - var steps: usize = 0; - walk: while (steps < find_max_steps and files_len < files.len) { - steps += 1; - const e = (try w.next(io)) orelse break; - if (e.kind == .directory) { - if (e.depth() >= find_max_depth) continue; - for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; - try w.enter(io, e); - continue; - } - if (e.kind != .file) continue; - files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); - files_len += 1; - } - } - std.mem.sort([]const u8, files[0..files_len], {}, struct { - fn lt(_: void, a: []const u8, b: []const u8) bool { - return std.mem.lessThan(u8, a, b); - } - }.lt); - // ONE bounded buffer reused for every file: a synchronous search must not - // swallow a file it cannot afford to hold. - const buf = try gpa.alloc(u8, grep_max_bytes); - defer gpa.free(buf); - for (files[0..files_len]) |path| { - if (hits >= find_max_hits or written == out.len) break; - var pathbuf: [4096]u8 = undefined; - const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - // A FILE THIS WALK CANNOT READ IS A FILE THIS WALK SKIPS. It used to - // abort the whole grep and report `OpenFailed`, so ONE root-owned 0600 - // file — or one deleted between the walk and the read, which is routine - // in a build tree — turned a search of ten thousand files into zero - // results and a word that explains nothing. A grep is a question about - // the files you can read; the ones you cannot are not an answer to it. - // NONBLOCK for the reason `readFile` has it: a FIFO in the tree would - // otherwise stop the search until somebody wrote to it. - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); - if (fd < 0) continue; - var len: usize = 0; - var readable = true; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // Skipped, not fatal, for the same reason: whatever this is, it - // is not text this search can answer with. - readable = false; - break; - } - if (n == 0) break; - len += @intCast(n); - } - _ = libc.close(fd); - if (!readable) continue; - const text = buf[0..len]; - if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; // binary - // per PATH, not per root: one root can straddle the asking pane's - // directory (a shell at `/a` searching for a file pane at `/a/b`), and - // the rows inside it are the ones worth shortening. The rule is - // lsp.rel's — under `base` means relative, anywhere else stays - // absolute — and it is THE one spelling now; this used to be an - // inline twin that the seam's own comment complained about. - const shown = lsp.rel(home, path); - const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); - hits += result.hits; - written += result.bytes; - } - return written; -} - -test "grep skips a file it cannot read instead of abandoning the search" { - if (!platform_has_fs) return; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; - - // Two files, and the unreadable one sorts FIRST — the walk reads in sorted - // order, so `a-` is the one that used to abort the search before `b-` was - // ever opened. - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" }); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" }); - var locked_buf: [std.fs.max_path_bytes]u8 = undefined; - const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0); - if (libc.chmod(locked, 0) != 0) return; - // Running as root reads it anyway, and then this test is testing nothing: - // say so by not pretending to have run. - const probe = libc.open(locked, .{ .ACCMODE = .RDONLY }); - if (probe >= 0) { - _ = libc.close(probe); - _ = libc.chmod(locked, 0o644); - return error.SkipZigTest; - } - - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - const out = try gpa.alloc(u8, 64 * 1024); - defer gpa.free(out); - const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out); - _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it - - // The readable file's hit came back. Before this, the whole call returned - // `error.OpenFailed` and the +Grep buffer was empty. - try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); - try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null); -} - - -/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) -fn isDir(path: [*:0]const u8) bool { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); - if (fd < 0) return false; - _ = libc.close(fd); - return true; -} - -/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web -/// reads from the generated source archive; native shells read the real fs. -const read_file_max_bytes = 256 * 1024 * 1024; -const read_stream_max_bytes = 4 * 1024 * 1024; - -/// Read a whole file with one size-bounded allocation. A file that grows after -/// fstat is read as the snapshot size; zero-size virtual files get a separate -/// bounded stream read. Files over either applicable cap are rejected. -pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { - if (!platform_has_fs) { - var normalized_buf: [4096]u8 = undefined; - const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed; - const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed; - if (source.contents.len > read_file_max_bytes) return error.FileTooLarge; - return gpa.dupe(u8, source.contents); - } - var pathbuf: [4096]u8 = undefined; - const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; - // NONBLOCK, and it is the difference between an error and a dead editor. - // A plain blocking `open` of a FIFO waits for a writer that may never come, - // and this call runs INSIDE the keystroke that asked for it — no frame, no - // message row, and in the tty shell no Ctrl-C either, because the terminal - // is in raw mode. `Look` on a named pipe (or on a device that blocks until - // carrier) froze the whole program with nothing on screen to say why. The - // flag is cleared again below for the file kinds that are worth reading; - // the ones that are not are refused by name. - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true }); - // WHY it would not open, not just that it would not. Every one of these is - // an ordinary thing to do by accident — `pardes /root`, a file left at mode - // 000, a name that was deleted between resolving and reading — and a caller - // that can only say "OpenFailed" has to show the human a word that means - // nothing to them. `errno` is libc's here, which is the only reason it can - // be read off a `-1`: see the raw-syscall note in `termCwd`. - if (fd < 0) return switch (libc.errno(fd)) { - .ACCES, .PERM => error.PermissionDenied, - .NOENT => error.FileNotFound, - .ISDIR => error.IsDirectory, - .NAMETOOLONG => error.PathTooLong, - else => error.OpenFailed, - }; - defer _ = libc.close(fd); - - // The flag STAYS SET, and the read loops below answer `EAGAIN` with - // `NotAFile`. A regular file ignores `O_NONBLOCK` entirely — the kernel - // never short-reads one for it — so this costs ordinary opens nothing and - // turns the one case that would have hung into an error with a name. - const end = libc.lseek(fd, 0, libc.SEEK.END); - // NOT SEEKABLE IS NOT A DOCUMENT. `lseek` answers `ESPIPE` for a pipe, a - // socket and a terminal, and those are exactly the things whose "contents" - // are a future rather than a file — with `O_NONBLOCK` above they no longer - // hang the editor, but an unwritten FIFO then reads as EOF and opened as a - // silent empty pane, which says even less than the hang did. The zero-size - // files that ARE worth streaming — procfs and its kin — seek fine and - // report 0, so they take the branch below untouched. - if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; - const size: usize = if (end < 0) 0 else @intCast(end); - if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; - if (size == 0) { - // procfs and similar virtual files report zero size. Probe once so a - // genuinely empty file remains an exact zero-byte allocation, then use - // one conservative bounded allocation for a non-empty stream. - var first: [16 * 1024]u8 = undefined; - var first_len: usize = 0; - while (true) { - const n = libc.read(fd, &first, first.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // Nothing to read AND nothing that will end: an empty pipe with - // no writer. This is the hang, reported instead of waited on. - if (libc.errno(n) == .AGAIN) return error.NotAFile; - return error.ReadFailed; - } - first_len = @intCast(n); - break; - } - if (first_len == 0) return gpa.alloc(u8, 0); - var stream = try gpa.alloc(u8, read_stream_max_bytes); - errdefer gpa.free(stream); - @memcpy(stream[0..first_len], first[0..first_len]); - var stream_len = first_len; - while (stream_len < stream.len) { - const n = libc.read(fd, stream[stream_len..].ptr, stream.len - stream_len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - // A stream that has paused is a stream that has ended, as far - // as one keystroke is concerned: keep what came. - if (libc.errno(n) == .AGAIN) break; - return error.ReadFailed; - } - if (n == 0) break; - stream_len += @intCast(n); - } - if (stream_len == stream.len) { - var extra: [1]u8 = undefined; - while (true) { - const n = libc.read(fd, &extra, 1); - if (n < 0 and libc.errno(n) == .INTR) continue; - if (n < 0 and libc.errno(n) == .AGAIN) break; - if (n < 0) return error.ReadFailed; - if (n > 0) return error.FileTooLarge; - break; - } - } - if (stream_len != stream.len) stream = try gpa.realloc(stream, stream_len); - return stream; - } - if (size > read_file_max_bytes) return error.FileTooLarge; - var buf = try gpa.alloc(u8, size); - errdefer gpa.free(buf); - var len: usize = 0; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) break; - len += @intCast(n); - } - if (len != buf.len) buf = try gpa.realloc(buf, len); - return buf; -} - -// ---- shell cwd: what directory a pane's looks resolve against ---- - -// macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the -// cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h. -const vnode_info_path = extern struct { - vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid - path: [1024]u8, // MAXPATHLEN -}; -const proc_vnodepathinfo = extern struct { - cdir: vnode_info_path, - rdir: vnode_info_path, -}; -const PROC_PIDVNODEPATHINFO: c_int = 9; -extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; - -/// Live cwd of a shell process (pane tags, look resolution). linux reads -/// /proc//cwd, darwin asks libproc; other POSIX systems have no cheap -/// answer — return null and panes keep their spawn-time cwd (callers already -/// tolerate failure: dead shells have no cwd either). -pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { - switch (builtin.os.tag) { - .linux => { - var pbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; - const n = libc.readlink(path, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - .macos, .ios, .tvos, .watchos, .visionos => { - var info: proc_vnodepathinfo = undefined; - const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); - if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; - const path = std.mem.sliceTo(&info.cdir.path, 0); - if (path.len == 0) return null; - @memcpy(buf[0..path.len], path); - return buf[0..path.len]; - }, - else => return null, - } -} - -// ---- tty occupancy: is a pane's terminal still the prompt pardes forked? ---- - -// Linux answers TIOCGPGRP asked of the pty MASTER with the SLAVE side's -// foreground process group — the number the kernel would deliver ^C to. Not in -// std.c, and the master is the only end pardes holds. -extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; - -/// How far the descendant walk goes before it stops trusting itself. A shell -/// sitting at its prompt has no descendants at all and a foreground job is one -/// hop, so these are not a budget, they are a fuse: the walk is driven by -/// numbers read out of the kernel and must not be able to spin on a surprising -/// one (the same reason nested.outer() caps its hops). Hitting either bound -/// answers OCCUPIED — a tree we did not finish reading may hide the foreground -/// job, and typing a command line into vim is worse than declining to type it -/// into a shell that really was idle under 32 background jobs. -const occ_max_depth: u8 = 8; -const occ_max_visited: usize = 32; - -/// Scratch for one `ttyTaken` answer: the walk's helpers share it rather than -/// each declaring its own copy of a path buffer. Lives in the probe's own -/// frame — there is no polling loop to hoist it out of any more, because the -/// core asks this question only where it is about to type a command line. -const TtyProbe = struct { - /// the forked shell's own executable, read once per probe - self_exe: [std.fs.max_path_bytes]u8 = undefined, - /// ...and one descendant's, to compare against it - exe: [std.fs.max_path_bytes]u8 = undefined, - /// one small /proc text at a time: a children list, a stat line, a status - /// blob. Each is consumed (parsed to numbers) before the next read. - blob: [4096]u8 = undefined, - /// the DFS worklist, bounded by the same fuse as the visit count - pending: [occ_max_visited]Node = undefined, - - const Node = struct { pid: libc.pid_t, depth: u8 }; -}; - -/// Is something OTHER than the shell prompt pardes forked sitting on this -/// pane's tty — vim, less, an agent, a build? An Exec must never type a command -/// line into such a program (it would land as vim keystrokes), so a taken -/// terminal is treated exactly like no terminal at all: the core routes the -/// command to another shell. -/// -/// The predicate, and the false answer each clause exists to prevent: -/// -/// fg = tcgetpgrp(master) the tty's foreground pgrp, from the kernel -/// fg < 0 -> free no answer at all (not a tty, a host that -/// does not allow the ioctl): behave as before -/// self = exe(shell_pid) the binary of the terminal we spawned, -/// straight out of /proc, so no spawn path has -/// to be plumbed through three frontends' Pty -/// structs and kept in step with shell_bin -/// self == null -> free the shell is gone; the pane's EOF is about -/// to remove it anyway -/// walk descendants of shell_pid: -/// exe unreadable -> occupied, unless the child is a zombie (or has -/// already vanished), which is provably not on -/// the tty. Unreadable-but-alive is a setuid -/// program — `sudo` waiting for a password is -/// the case that must NOT be typed into. -/// exe != self -> occupied iff its pgrp is fg. The pgrp filter is -/// what keeps `sleep 30 &` from looking -/// occupied: a background job is a child of an -/// idle prompt, and its pgrp is not the tty's. -/// exe == self -> recurse. A nested shell prompt is still a usable -/// prompt, so `bash` inside `bash` stays -/// Exec-able; and the leaf is the answer, which -/// is what catches `bash -c 'sleep 30'` — there -/// the foreground pgrp LEADER's exe is our own -/// shell binary while the tty really belongs to -/// `sleep`. -/// no visited process in pgrp fg, and fg != shell_pid -/// -> occupied the tty belongs to a group we could not -/// attribute to anything we forked (a -/// foreground leader that died or re-parented); -/// never type into it. -/// otherwise -> free -pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { - switch (builtin.os.tag) { - .linux => { - var probe: TtyProbe = undefined; - const fg = tcgetpgrp(master_fd); - if (fg < 0) return false; - const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; - - // The shell's own pgrp is normally the tty's when it is at its - // prompt (forkpty made it the session and group leader), so the - // idle answer is reached without reading its stat at all — the - // whole fast path is tcgetpgrp, one readlink, and an empty - // children file. - var saw_fg = fg == shell_pid; - var pending: usize = 0; - var visited: usize = 0; - switch (pushChildren(&probe, &pending, shell_pid, 1)) { - .pushed => {}, - // No children file: a kernel without CONFIG_PROC_CHILDREN - // cannot answer this question at all, so answer free and leave - // behaviour exactly as it was before this probe existed. - .unreadable => return false, - .full => return true, - } - - while (pending > 0) { - pending -= 1; - const node = probe.pending[pending]; - visited += 1; - if (visited > occ_max_visited) return true; - - // One stat read carries the group; note it before anything can - // return, because the final clause is about every process we - // looked at, not only the ones that decided the answer. - const pgrp = procPgrp(node.pid, &probe.blob); - if (pgrp) |g| { - if (g == fg) saw_fg = true; - } - - const exe = procExe(node.pid, &probe.exe) orelse { - if (offTty(node.pid, &probe.blob)) continue; - return true; - }; - if (!std.mem.eql(u8, exe, self_exe)) { - if (pgrp) |g| if (g == fg) return true; - continue; - } - if (node.depth >= occ_max_depth) return true; - switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { - .pushed => {}, - // This one exited while we walked (or the kernel stopped - // answering for it); its own pgrp was already counted and - // there is nothing below it to learn. - .unreadable => {}, - .full => return true, - } - } - return !saw_fg; - }, - // A darwin implementation is tcgetpgrp (which xnu also allows on the - // master) plus a descendant walk built from proc_listchildpids, with - // proc_pidpath for the exe and proc_bsdinfo's pbi_pgid for the group — - // there is no /proc to read. Until then macOS behaves as it did before - // this probe existed: every terminal is a prompt. - else => return false, - } -} - -/// DELIVER A SIGNAL TO WHATEVER IS ON THIS PANE'S TTY — the host half of -/// `pty/ctl`'s `sig` verb, shared by every frontend that owns pane shells so -/// that the target is decided once instead of three times. -/// -/// THE TARGET IS THE FOREGROUND PROCESS GROUP, not the shell's pid, and the -/// difference is the whole usefulness of the verb. `tcgetpgrp` on the master -/// answers with the number the kernel would deliver a ^C to (see the comment -/// on the declaration above), which is the running build, the pager, the -/// agent — the thing a script means when it says `sig INT`. Aimed at the pid -/// instead, `sig INT` would reach an interactive shell, which ignores SIGINT -/// while it waits for a job: the verb would appear to work and do nothing on -/// the one case anybody wants it for. At an idle prompt the two are the same -/// number, because forkpty made the shell its own group leader. -/// -/// The pid is the FALLBACK, for an OS or a host whose master end will not -/// answer the ioctl. There `sig KILL` still ends the shell, which is the case -/// where being ignored is not an acceptable outcome. -pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void { - // Whatever `std.c.SIG` spells these as on this platform, unconverted: the - // one call below wants exactly that type (see the `kill` beside `harvest`). - const sig = switch (which) { - .int => libc.SIG.INT, - .term => libc.SIG.TERM, - .hup => libc.SIG.HUP, - .quit => libc.SIG.QUIT, - .kill => libc.SIG.KILL, - }; - const fg = tcgetpgrp(master_fd); - // A process GROUP is addressed as its negated leader; `fg` is already a - // group id, so this is `kill(-fg)` and not `kill(-leader_of(fg))`. - if (fg > 0) { - _ = libc.kill(-fg, sig); - return; - } - if (shell_pid > 0) _ = libc.kill(shell_pid, sig); -} - -/// Read a small /proc text in one go. These files are generated on read and -/// answer completely in a single call at these sizes; a short read would only -/// truncate a field, which every parser below treats as "no answer". -fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - defer _ = libc.close(fd); - const got = libc.read(fd, buf.ptr, buf.len); - if (got <= 0) return null; - return buf[0..@intCast(got)]; -} - -/// The binary behind a pid, as the kernel spells it. Fails for a zombie (no mm -/// to point at) and for a process we may not inspect — the two cases `ttyTaken` -/// has to tell apart. -fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { - var name: [64:0]u8 = undefined; - const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(link, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; -} - -/// A pid's process group. -fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { - var name: [64:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; - return parsePgrp(readProc(path, buf) orelse return null); -} - -/// Field 5 of /proc//stat, found by scanning back from the LAST ')' -/// rather than counting fields from the start: field 2 is `comm` in -/// parentheses, and a comm may contain spaces AND parentheses, so a process -/// named `sh (a b)` shifts everything after it and a positional parse silently -/// reads some other number as the group. Same trap nested.parsePPid documents; -/// the kernel puts comm's closing paren last precisely so this scan works. -fn parsePgrp(stat: []const u8) ?libc.pid_t { - const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; - var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); - _ = fields.next() orelse return null; // 3: state - _ = fields.next() orelse return null; // 4: ppid - const pgrp = fields.next() orelse return null; // 5: pgrp - return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; -} - -/// Is this pid provably NOT holding the tty even though its exe is unreadable: -/// a zombie (dead, waiting to be reaped) or already gone. Everything else that -/// hides its exe — a setuid program — is alive and on the terminal. -fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { - var name: [64:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; - // No status at all: the pid died between the children read and here. A - // process that no longer exists cannot be typed into. - const status = readProc(path, buf) orelse return true; - return parseZombie(status); -} - -/// The `State:` field of a /proc//status blob, and only Z. Line-anchored, -/// so a comm that spells `State: Z` inside the `Name:` line cannot answer. -fn parseZombie(status: []const u8) bool { - var lines = std.mem.splitScalar(u8, status, '\n'); - while (lines.next()) |line| { - if (!std.mem.startsWith(u8, line, "State:")) continue; - const state = std.mem.trim(u8, line["State:".len..], " \t\r"); - return state.len > 0 and state[0] == 'Z'; - } - return false; -} - -const Pushed = enum { pushed, unreadable, full }; - -/// Put a pid's direct children on the worklist. The children file is the whole -/// reason this walk is cheap: an idle shell's is empty, so the fast path reads -/// one empty file instead of scanning /proc. -/// -/// Spelled out rather than routed through `readProc` precisely because of that -/// empty file: readProc treats a zero-byte answer as no answer, which is right -/// for a stat line and exactly wrong here — "this process has no children" is -/// the most informative reply the walk ever gets, and calling it unreadable -/// would make the whole probe give up on every idle shell. -fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { - var name: [96:0]u8 = undefined; - const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ - @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), - }, 0) catch return .unreadable; - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return .unreadable; - defer _ = libc.close(fd); - const got = libc.read(fd, &probe.blob, probe.blob.len); - if (got < 0) return .unreadable; - - var kids: [occ_max_visited]libc.pid_t = undefined; - const total = parseChildren(probe.blob[0..@intCast(got)], &kids); - if (total > kids.len or pending.* + total > probe.pending.len) return .full; - for (kids[0..total]) |kid| { - probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; - pending.* += 1; - } - return .pushed; -} - -/// The pids in a /proc//task//children blob: space separated, with a -/// trailing space, and empty for the overwhelmingly common idle shell. Returns -/// how many valid pids the blob HAS, having written the first `out.len` of them -/// — a total past `out.len` is the caller's overflow signal. A token that is -/// not strictly digits is skipped rather than answered wrong: this drives who -/// gets walked, and parseInt alone would take `-1` and `+7`. -fn parseChildren(text: []const u8, out: []libc.pid_t) usize { - var total: usize = 0; - var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); - while (it.next()) |tok| { - if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; - const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; - if (total < out.len) out[total] = kid; - total += 1; - } - return total; -} - -test "the children blob parses to pids, and a garbage token never becomes one" { - var out: [8]libc.pid_t = undefined; - // the idle shell, which is the case the whole fast path is shaped around - try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); - try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); - // one child — the kernel writes a TRAILING space and no newline - try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); - try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); - // several, with and without the trailing separator - try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); - try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); - try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); - // garbage: this list decides whose /proc entries get read, and parseInt - // alone would take every one of these. The pids AROUND the junk still - // answer — dropping the tree because one token was odd would silently turn - // a busy terminal into a free one. - try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); - // overflow is REPORTED, not silently truncated: the total is what the blob - // HAS, so the caller can answer "occupied" instead of walking a tree it - // only partly read - var two: [2]libc.pid_t = undefined; - try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); - try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); -} - -test "the process group comes off the last ')', not a comm-shifted stat field" { - // the comm here contains a space AND parentheses — the exact shape that - // breaks `field 5 of /proc//stat` (see nested.parsePPid). Counting - // from the left answers `b))` for the state and `S` for the group. - const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ - "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; - try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); - // ...and the ordinary shape still reads the same field - try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); - // a group of its own, which is what a background job has - try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); - // a truncated read must not answer from a half line, and a blob that is - // not a stat line at all must not answer at all - try std.testing.expect(parsePgrp("") == null); - try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); - try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); - try std.testing.expect(parsePgrp("no parens here at all") == null); -} - -test "the zombie state comes off its own status line" { - // a reaped-but-not-yet-collected child: no exe to read, and provably not - // holding the tty, so the walk must skip it instead of answering occupied - try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); - try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); - // every other state is a live process, and an unreadable exe then means - // setuid (sudo asking for a password) — the one thing never to type into - try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); - try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); - try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); - // a comm that spells the field cannot answer for it: the scan is anchored - // to the start of a line, and `Name:` is where a comm lives - try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); - // a truncated read is not a zombie (and so stays conservative) - try std.testing.expect(!parseZombie("Name:\tsh\nSta")); - try std.testing.expect(!parseZombie("State:\t")); -} - -// ---- tests: the predicate against real processes on a real pty ---- -// -// The parsers above cannot see any of what follows: whether Linux answers -// TIOCGPGRP on the MASTER at all, whether bash really puts a background job in -// its own group, and whether `bash -c` leaves our own binary as the foreground -// leader are all facts about the system, and every one of them decides an -// answer. So these fork a real bash on a real pty — the way -// test/e2e_harness.zig forks the whole app — and drive it. -extern "c" fn forkpty( - amaster: *c_int, - name: ?[*:0]u8, - termp: ?*const anyopaque, - winp: ?*const std.posix.winsize, -) c_int; - -const test_shell = "/bin/bash"; -const test_prompt = "PZX> "; - -/// A real interactive bash on a pty of our own, plus the polling the cases need. -/// Nothing here sleeps for a fixed time waiting for the shell: every step polls -/// to a deadline, and every poll DRAINS the master — a shell whose output is -/// never read blocks on a full pty buffer and then nothing else happens either. -const TestShell = struct { - master: c_int, - pid: libc.pid_t, - /// a rolling window of what the shell has written, so a case can wait for - /// the prompt (or a job-control notice) instead of guessing a duration - tail: [8192]u8 = undefined, - tail_len: usize = 0, - - fn start() ?TestShell { - if (!haveFile(test_shell)) return null; - var master: c_int = undefined; - const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; - const pid = forkpty(&master, null, null, &ws); - if (pid < 0) return null; - if (pid == 0) { - // --norc: the developer's own bashrc must not decide what these - // tests see. -i: job control, which is what puts a background job - // in a group of its own and is half of what is under test. - const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; - _ = execv(test_shell, &argv); - _exit(127); - } - var sh: TestShell = .{ .master = master, .pid = pid }; - // A prompt of our own — EXPORTED, so a nested bash shows the same one — - // spelled with a '' seam, so the echo of the command that sets it - // cannot be mistaken for the prompt it produces. - sh.send("export PS1='PZ''X> '\n"); - if (!sh.waitText(test_prompt, 10_000)) { - sh.stop(); - return null; - } - sh.forget(); - return sh; - } - - fn send(sh: *TestShell, bytes: []const u8) void { - _ = libc.write(sh.master, bytes.ptr, bytes.len); - } - - fn forget(sh: *TestShell) void { - sh.tail_len = 0; - } - - /// Read everything the shell has produced so far, without blocking. - fn drain(sh: *TestShell) void { - while (true) { - var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; - if (libc.poll(&fds, 1, 0) <= 0) return; - if (fds[0].revents & libc.POLL.IN == 0) return; - var chunk: [4096]u8 = undefined; - const n = libc.read(sh.master, &chunk, chunk.len); - if (n <= 0) return; - sh.append(chunk[0..@intCast(n)]); - } - } - - fn append(sh: *TestShell, bytes: []const u8) void { - if (bytes.len >= sh.tail.len) { - @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); - sh.tail_len = sh.tail.len; - return; - } - const room = sh.tail.len - sh.tail_len; - if (bytes.len > room) { - const drop = bytes.len - room; - std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); - sh.tail_len -= drop; - } - @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); - sh.tail_len += bytes.len; - } - - fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { - const deadline = nowMs() + ms; - while (true) { - sh.drain(); - if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; - if (nowMs() >= deadline) return false; - sleepMs(5); - } - } - - fn taken(sh: *TestShell) bool { - sh.drain(); - return ttyTaken(sh.pid, sh.master); - } - - /// Poll until the verdict is `want` — the answer changes when the SHELL - /// gets around to forking or reaping, not when we sent the line. - fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { - const deadline = nowMs() + ms; - while (true) { - if (sh.taken() == want) return true; - if (nowMs() >= deadline) return false; - sleepMs(5); - } - } - - /// ...and the other direction: the verdict STAYS `want` for a window. What - /// a false positive looks like is a probe that flickers to occupied while - /// the shell sits at its prompt with a background job, and a single sample - /// can miss it. - fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { - const deadline = nowMs() + ms; - while (nowMs() < deadline) { - if (sh.taken() != want) return false; - sleepMs(5); - } - return true; - } - - /// Kill the shell AND everything under it, then reap and close. The tree - /// has to be collected BEFORE the shell dies: a foreground job lives in its - /// own process group, so killing bash alone leaves `sleep 30` running, - /// re-parented to init — a stray that outlives the test binary. - fn stop(sh: *TestShell) void { - var probe: TtyProbe = undefined; - var pending: usize = 0; - var doomed: [occ_max_visited]libc.pid_t = undefined; - var n: usize = 0; - _ = pushChildren(&probe, &pending, sh.pid, 1); - while (pending > 0) { - pending -= 1; - const node = probe.pending[pending]; - if (n == doomed.len) break; - doomed[n] = node.pid; - n += 1; - if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); - } - _ = libc.kill(sh.pid, libc.SIG.KILL); - for (doomed[0..n]) |kid| { - _ = libc.kill(kid, libc.SIG.KILL); - // ...and its group, for a program that forked helpers of its own - _ = libc.kill(-kid, libc.SIG.KILL); - } - _ = libc.waitpid(sh.pid, null, 0); - _ = libc.close(sh.master); - } -}; - -fn haveFile(path: [*:0]const u8) bool { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return false; - _ = libc.close(fd); - return true; -} - -fn nowMs() i64 { - var ts: libc.timespec = undefined; - _ = libc.clock_gettime(.MONOTONIC, &ts); - return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); -} - -fn sleepMs(ms: i64) void { - const ts = libc.timespec{ - .sec = @intCast(@divFloor(ms, 1000)), - .nsec = @intCast(@mod(ms, 1000) * 1_000_000), - }; - _ = libc.nanosleep(&ts, null); -} - -test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // The whole point of the default: a shell sitting at its prompt is usable, - // and stays usable across samples. - try std.testing.expect(sh.holdsTaken(false, 200)); - - // A foreground job IS the terminal now — this is the answer an Exec needs, - // and typing a command line here would be typing it at `sleep`. - sh.send("sleep 30\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - - // ^C, and the tty is the prompt's again. Nothing is cached: the next poll - // simply finds no children, which is why recovery needs no event. - sh.forget(); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); -} - -test "a background job is not the tty's owner" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // The false positive the pgrp filter exists for. Waiting for the job - // notice first matters: the verdict has to be taken while the child is - // genuinely alive, or this test would pass with no probe at all. - sh.send("sleep 30 &\n"); - try std.testing.expect(sh.waitText("[1]", 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // ...and it is still free once the job is gone, which also means the - // zombie between `kill` and bash's reap is not read as an occupant. - sh.send("kill %1\n"); - try std.testing.expect(sh.holdsTaken(false, 300)); -} - -test "a nested interactive shell is still a prompt" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - // `bash` inside `bash`: the leaf matches the binary we spawned, so it is a - // prompt like any other and Exec must keep working. This is the case the - // recursion is FOR, and the reason "any child at all" would be wrong. - sh.forget(); - sh.send("bash --norc -i\n"); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // ...and one level deeper still - sh.forget(); - sh.send("bash --norc -i\n"); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - try std.testing.expect(sh.holdsTaken(false, 300)); - - // a job inside the INNER shell is still the tty's owner - sh.send("sleep 30\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); -} - -test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - - sh.send("bash --norc -c 'sleep 30'\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); - - // The same shape where bash provably CANNOT exec the command in place (two - // commands, so the wrapper has to stay around and fork): the foreground - // group's leader is then our own shell binary while the tty really belongs - // to `sleep`. A predicate that stopped at the leader would call this free. - sh.send("bash --norc -c 'sleep 30; :'\n"); - try std.testing.expect(sh.waitTaken(true, 10_000)); - - // ...and that is the shape asserted, not assumed: the shell's only child - // runs the same binary the shell does. - var probe: TtyProbe = undefined; - var pending: usize = 0; - try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); - try std.testing.expectEqual(@as(usize, 1), pending); - var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; - var shell_buf: [std.fs.max_path_bytes]u8 = undefined; - try std.testing.expectEqualStrings( - procExe(sh.pid, &shell_buf).?, - procExe(probe.pending[0].pid, &wrapper_buf).?, - ); - - sh.send("\x03"); - try std.testing.expect(sh.waitTaken(false, 10_000)); -} - -test "a full-screen program takes the tty until it quits" { - if (comptime builtin.os.tag != .linux) return error.SkipZigTest; - // The two shapes a human actually loses a terminal to: an editor that takes - // the alternate screen, and a pager that does not. Both are skipped rather - // than failed where they are not installed. - const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ - // -u NONE -i NONE: no vimrc, no viminfo — this must not touch the - // developer's own files, and an rc that starts a plugin would change - // the process tree under test. - .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, - // LESS= so a developer's own -F (quit if one screen) cannot make the - // pager exit before it is asked to - .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, - }; - var ran: usize = 0; - for (cases) |c| { - if (!haveFile(c.bin)) continue; - var sh = TestShell.start() orelse return error.SkipZigTest; - defer sh.stop(); - sh.send(c.run); - try std.testing.expect(sh.waitTaken(true, 10_000)); - sh.forget(); - sh.send(c.quit); - try std.testing.expect(sh.waitTaken(false, 10_000)); - try std.testing.expect(sh.waitText(test_prompt, 10_000)); - ran += 1; - } - if (ran == 0) return error.SkipZigTest; + const found = fs.resolve(p, word, cwd, realbuf) orelse return .none; + if (found.dir) return .{ .dir = found.path }; + if (comptime pdf_enabled) if (isPdfPath(found.path)) return .{ .file = .{ + .path = found.path, + .at = pl.at, + .kind = .pdf, + } }; + if (isImagePath(found.path)) return .{ .image = .{ .path = found.path } }; + return .{ .file = .{ .path = found.path, .at = pl.at } }; } diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 0b4f2ba1..ca19009a 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -1,216 +1,111 @@ -//! The language-intelligence seam. -//! -//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect -//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker -//! and posts the answer back as an `lsp_resp` Event. That is the whole async -//! execution model — the same shape the pty readers already use, because a -//! language query is just another thing that answers later. -//! -//! Location answers render as `+Search` rows. A location is -//! `path:LINE:COL text` — or `path:LINE:COL-ENDCOL text` where the protocol -//! answered with a real range, which a look then SELECTS — and that is what -//! look.zig already resolves and what n/N already steps, so a multi-result -//! answer IS helix's picker and a single result IS a jump, with no picker UI -//! written for it. Free text (hover, formatting) rides the same buffer. Rename -//! is the one mutating answer: it emits byte ranges through `edit`, and the core -//! applies them atomically only while the source revision is still current. -//! -//! `query` is the ONLY thing an implementation supplies. Swapping backends is -//! swapping this one function, which is also how the three competing -//! implementations are measured against each other: same core, same harness, -//! same rows, different `query`. const std = @import("std"); -/// What the caller wants to know. The helix command each one backs is named -/// alongside, because the keymap is helix's and these are its verbs — helix's -/// bare `X` spelled `SPC l X` here, because `d`, `k`, `s` and `h` were -/// already pardes's own most-pressed leader keys and the rest follow them into -/// the group rather than splitting the menu (see config.leader_path). pub const Kind = enum { - /// gd definition, - /// gD declaration, - /// gy type_definition, - /// gi implementation, - /// gr references, - /// SPC l k hover, - /// SPC l s document_symbols, - /// SPC l S (arg = the query) workspace_symbols, - /// SPC l d, and the list that ]d / [d step diagnostics, - /// SPC l D workspace_diagnostics, - /// SPC l r (arg = the new name) rename, - /// SPC l a code_action, - /// = format, - /// SPC l h select_refs, - /// Tab in insert mode, with a `.` immediately before the cursor. NOT an - /// autocomplete popup — the seam returns locations, so this answers "what - /// could go here, and where is each of those DEFINED": one row per - /// candidate, pointing at its declaration, in the same `+Search` buffer - /// `gr` fills. Nothing is inserted. completion, - - // The two-step hierarchy kinds, LSP 3.16/3.17: prepare at the cursor, - // then walk the item the server handed back. helix has none of these - // four (checked against helix-term/src/keymap/default.rs, which stops at - // the gotos), so they are pardes exceeding parity rather than matching - // it — possible here because the answers are LOCATIONS, and locations - // are the one thing this seam renders for free. - /// SPC l c — who calls the function under the cursor incoming_calls, - /// SPC l C — everything the function under the cursor calls outgoing_calls, - /// SPC l t — the types this one extends/implements supertypes, - /// SPC l T — the types that extend/implement this one subtypes, - - // The two introspection kinds. A backend that answers nothing is - // indistinguishable from a backend that is broken, so these exist to tell - // those apart — they are the only Kinds whose answer is ABOUT the backend - // rather than about the code. - /// SPC l i — configuration, capabilities and the recent-query log status, - /// SPC l w — why the query at the cursor answers what it does. Narrates - /// the REAL resolution path rather than re-deriving it, so it cannot drift - /// away from what `gd` actually did. explain, - - // What an ANSWER becomes — which buffer it opens, whether a single row - // jumps instead, whether n/N walk it — is not here: it is one row per Kind - // in output_pane.traits, beside the same questions asked of `/`, Find, - // Grep and Help. A Kind added above will not compile until it has one. }; -/// One question. `source` is a snapshot of the buffer taken by the shell -/// before the worker starts — the core keeps editing while this is in flight, -/// so a backend must never reach back into core memory. pub const Req = struct { kind: Kind, - /// absolute path of the file the offset is in path: []const u8, - /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a - /// sentinel, and every backend has to parse this same text) source: [:0]const u8, - /// cursor position, a byte offset into `source` offset: u32, - /// kind-specific argument: the new name for a rename, the query for - /// workspace symbols. Empty otherwise. arg: []const u8 = "", - /// where the project starts — the directory of the pane that asked. A - /// backend that indexes more than one file walks from here. root: []const u8 = "", }; -/// How a row SPELLS a path: relative to `base` if it lives UNDER it, its full -/// absolute self otherwise. -/// -/// `base` is `Req.root` — the directory of the file the query was asked about -/// — which is also the directory the results buffer is opened in, so a row -/// shortened here reads as the name that window would have typed and still -/// resolves when looked. `gr` over one file was otherwise the same -/// forty-character absolute prefix repeated down the whole pane, with the part -/// you came to read pushed off the right edge. -/// -/// UNDER, not "shorter": a path outside that tree is left absolute rather than -/// walked up to with `../`. An absolute path resolves from anywhere and says -/// where it is; `../../..` says neither, and the moment the row is read -/// somewhere other than beside its own buffer it is wrong. -/// -/// This is also `look.grep`'s `shown` rule — it calls this function, so the -/// two spellings the docs used to complain about are one. +const backends = if (@import("pardes_config").zls_backend) + .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } +else + .{}; + +pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; + +pub const supports: std.EnumSet(Kind) = blk: { + var s: std.EnumSet(Kind) = .initEmpty(); + for (0..backends.len) |i| s.setUnion(backends[i].supports); + break :blk s; +}; + +pub fn speaks(path: []const u8) bool { + inline for (backends) |b| if (b.speaks(path)) return true; + return false; +} + +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void { + if (req.kind == .status) { + inline for (backends) |b| try b.query(gpa, arena, req, out); + return; + } + inline for (backends) |b| { + if (b.speaks(req.path) and b.supports.contains(req.kind)) + return b.query(gpa, arena, req, out); + } + if (req.kind == .explain and backends.len > 0) + try backends[0].query(gpa, arena, req, out); +} + +// Called on server reader threads; the sink must copy text before returning. +pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { + if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); +} + pub fn rel(base: []const u8, path: []const u8) []const u8 { if (base.len == 0) return path; - const home = std.mem.trimEnd(u8, base, "/"); - if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/') - return path[home.len + 1 ..]; + const prefix = std.mem.trimEnd(u8, base, "/"); + if (path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/') + return path[prefix.len + 1 ..]; return path; } -/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way -/// every other row in a `+Search` buffer is (and the way look.zig parses one). -/// `path` has already been through `rel`: the caller holds the base. -pub fn row( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - text: []const u8, -) void { - out.print("{s}:{d}:{d} {s}\n", .{ +pub fn row(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("{s}:{d}:{d} {s}\n", .{ path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// The same row for a protocol RANGE: `path:LINE:COL-ENDCOL`, which a look -/// SELECTS rather than parking on its first cell — so `gd` lands on the whole -/// name and a references list steps symbol by symbol with each one highlighted -/// (config.range_sep spells the dash; `-` is written out here for the same -/// reason `:` is). -/// -/// `end_col` is the protocol's own EXCLUSIVE end character, which is already -/// the 1-based inclusive column pardes wants, so the conversion is the absence -/// of one. A span that is empty or crosses lines falls back to the point row: -/// the only multi-line ranges here are whole declarations, and a goto onto one -/// wants the cursor at its name, not its body painted. -pub fn spanRow( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - end_line: usize, - end_col: usize, - text: []const u8, -) void { +// Input positions are zero-based and end-exclusive; displayed spans are one-based and inclusive. +pub fn spanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void { if (end_line != line or end_col <= col) return row(out, path, line, col, text); - out.print("{s}:{d}:{d}-{d} {s}\n", .{ + try out.print("{s}:{d}:{d}-{d} {s}\n", .{ path, line + 1, col + 1, end_col, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; + }); } -/// Emit one half-open byte range for a mutating response. Rename is the only -/// current user: every other answer remains human-readable rows. Byte offsets -/// avoid converting the displayed 1-based locations back into source offsets -/// in the core, and the prefix makes malformed or mixed responses fail closed. -pub fn edit(out: *std.Io.Writer, start: usize, end: usize) void { - out.print("@edit {d} {d}\n", .{ start, end }) catch {}; +pub fn edit(out: *std.Io.Writer, start: usize, end: usize) std.Io.Writer.Error!void { + try out.print("@edit {d} {d}\n", .{ start, end }); } -/// The general mutating record: a half-open byte range REPLACED BY `text`, -/// which `@edit` cannot say (its replacement is the request's own arg, the -/// same for every range). Rename through a protocol server and `=` both need -/// per-range text, so this carries it — percent-encoded onto the one line a -/// record is allowed to be, because a TextEdit's newText is full of newlines -/// and the record stream is parsed line by line. The core decodes with -/// `parseLspEdits` and applies all records in one undo transaction; malformed, -/// overlapping or out-of-bounds records change nothing, exactly as for @edit. -pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) void { - out.print("@put {d} {d} ", .{ start, end }) catch {}; +pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) std.Io.Writer.Error!void { + try out.print("@put {d} {d} ", .{ start, end }); for (text) |c| { - // '%' so the encoding round-trips; control bytes so the record stays - // one line; ' ' so the text is one token. Everything else is itself. - if (c == '%' or c == ' ' or c < 0x21) - out.print("%{X:0>2}", .{c}) catch {} + if (c == '%' or c < 0x21) + try out.print("%{X:0>2}", .{c}) else - out.writeByte(c) catch {}; + try out.writeByte(c); } - out.writeByte('\n') catch {}; + try out.writeByte('\n'); } -/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn -/// an AST token into a row, so it lives here rather than three times over. pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } { const upto = source[0..@min(offset, source.len)]; const line = std.mem.count(u8, upto, "\n"); @@ -218,82 +113,29 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi return .{ .line = line, .col = upto.len - bol }; } -/// Answer `req`, writing rows to `out`. Runs on a worker thread with no -/// access to the core: everything it may read is in `req`. -/// -/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an -/// `Io.Writer.Allocating`), so a backend never allocates the result, never -/// frees it, and cannot get the allocator wrong. Write failures are the -/// writer's problem; a backend may ignore them. -/// -/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's -/// own longer-lived scratch. Errors are not reported — a backend that cannot -/// answer writes nothing, and the core treats "no rows" as "no result", which -/// is also what a language server still starting up looks like. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void { - // `status` is about the BACKENDS, plural: every one reports, in seam - // order, so `SPC l i` shows the analyser and the protocol client side by - // side and a machine with neither prints nothing at all. - if (req.kind == .status) { - inline for (backends) |b| b.query(gpa, arena, req, out); - return; +test "LSP encoders report every insufficient output capacity" { + const cases = [_]struct { kind: enum { row, span, edit, put }, expected: []const u8 }{ + .{ .kind = .row, .expected = "file:1:3 hi\n" }, + .{ .kind = .span, .expected = "file:1:3-5 hi\n" }, + .{ .kind = .edit, .expected = "@edit 1 3\n" }, + .{ .kind = .put, .expected = "@put 1 3 hé%20%25%0A\n" }, + }; + for (cases) |case| { + var buf: [128]u8 = undefined; + for (0..case.expected.len + 1) |capacity| { + var out: std.Io.Writer = .fixed(buf[0..capacity]); + const result = switch (case.kind) { + .row => row(&out, "file", 0, 2, " hi \n"), + .span => spanRow(&out, "file", 0, 2, 0, 5, " hi \n"), + .edit => edit(&out, 1, 3), + .put => put(&out, 1, 3, "hé %\n"), + }; + if (capacity < case.expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(case.expected, out.buffered()); + } + } } - inline for (backends) |b| { - if (b.speaks(req.path) and b.supports.contains(req.kind)) - return b.query(gpa, arena, req, out); - } - // Nobody spoke the file. `explain` exists precisely to narrate a refusal, - // so it still goes to the first backend, whose trace says WHY it stopped - // ("not a .zig file", "no server for .md") instead of silently no-rowing. - if (req.kind == .explain and backends.len > 0) - backends[0].query(gpa, arena, req, out); } - -/// The compiled-in backends, asked in order; the first one that speaks the -/// file's language AND claims the kind answers. Two on a native build — ZLS -/// linked as a module for Zig (no process, cold is warm), and a real LSP -/// client (lsp_client.zig) speaking JSON-RPC to child servers for everything -/// else: rust-analyzer, clangd, gopls, whatever the spec table names. A -/// FREESTANDING core (web, esp32) compiles in neither: `supports` is then -/// empty, `lspRequest` returns before it emits, and the effect never exists. -const backends = if (@import("pardes_config").zls_backend) - .{ @import("lsp_zls.zig"), @import("lsp_client.zig") } -else - .{}; - -/// What this backend can actually answer, for the evaluation harness and for -/// the core (a Kind that is not supported never leaves the keymap). An -/// implementation narrows this to what it really does — claiming a feature it -/// does not have shows up immediately in the harness's matrix. -pub const supports: std.EnumSet(Kind) = blk: { - var s: std.EnumSet(Kind) = .initEmpty(); - for (0..backends.len) |i| s.setUnion(backends[i].supports); - break :blk s; -}; - -/// Does the backend read this file's LANGUAGE at all? `supports` answers what -/// a backend can do; this answers what it can do it TO, and it exists for the -/// one key that must not be eaten when the answer is no: insert-mode Tab -/// diverts to `completion` after a `.`, so in a README — or in any pane the -/// backend would refuse — it has to indent instead. The core asks rather than -/// knowing, so the list of extensions stays the backend's business. -pub fn speaks(path: []const u8) bool { - inline for (backends) |b| if (b.speaks(path)) return true; - return false; -} - -/// Where a shell registers the one function unsolicited SERVER STATE goes -/// through: "rust-analyzer indexing 3/120", "gopls exited". Called from the -/// client's reader threads, so a sink must be thread-safe and must copy -/// `text` before returning; both native shells post it to their event queue -/// and let the loop hand it to `Pardes.setMessage` — the same transient row a -/// save narrates into, because a server starting up is exactly that kind of -/// news. A build with no client accepts and ignores the registration. -pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void { - if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb); -} - -/// Name shown by the harness and in `SPC ?`. This is the SEAM's, not the -/// backend's: a backend does not declare it, so renaming a backend means -/// editing this line. -pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc"; diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index b1be590b..8c6a0da9 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -189,7 +189,7 @@ const wedged_strikes = 3; // consecutive timeouts before a restart const max_rows = 2000; const max_doc_bytes = 8 << 20; -const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer }; +const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer, WriteFailed }; /// Long-lived state outlives every query arena and cannot borrow the caller's /// gpa (a different one shows up in the harness than in the shell), so @@ -399,11 +399,9 @@ const Trace = struct { // ------------------------------------------------------------------- query -/// The seam entry point. Never fails, never panics; no rows is the only error -/// rendering there is (`SPC l i` shows what was swallowed). -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { _ = gpa; - if (req.kind == .status) return status(req, out) catch {}; + if (req.kind == .status) return status(req, out); var tr: Trace = .{ .on = req.kind == .explain }; const si = specFor(req.path) orelse { @@ -414,33 +412,36 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out }; tr.note("file {s} -> {s} (languageId {s})", .{ std.fs.path.basename(req.path), specs[si].name, specs[si].lang }); - const scratch_buf = arena.alloc(u8, max_rows * 512) catch return; + const scratch_buf = try arena.alloc(u8, max_rows * 512); var scratch: std.Io.Writer = .fixed(scratch_buf); const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; answer(arena, si, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); } -fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) void { +fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void { if (req.kind != .explain) return; - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.buf[0..tr.len]) catch {}; + }); + try out.writeAll(tr.buf[0..tr.len]); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void { @@ -470,7 +471,7 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer } } }, - error.OutOfMemory, error.NoServer => {}, + error.OutOfMemory, error.NoServer, error.WriteFailed => {}, } return e; }; @@ -519,7 +520,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io .select_refs => { const b = try atPos(arena, uri.items, pos); const result = (try call(c, arena, "textDocument/documentHighlight", b.items, deadline)) orelse return; - for (items(result)) |h| emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); + for (items(result)) |h| try emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); }, .hover => { const b = try atPos(arena, uri.items, pos); @@ -530,8 +531,8 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io break :blk ""; }; if (text.len == 0) return; - out.writeAll(std.mem.trim(u8, text, " \t\r\n")) catch {}; - out.writeByte('\n') catch {}; + try out.writeAll(std.mem.trim(u8, text, " \t\r\n")); + try out.writeByte('\n'); }, .document_symbols => { var b: std.ArrayList(u8) = .empty; @@ -539,7 +540,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io try jstr(&b, arena, uri.items); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/documentSymbol", b.items, deadline)) orelse return; - walkSymbols(&cx, c.caps.enc, uri.items, result, 0); + try walkSymbols(&cx, c.caps.enc, uri.items, result, 0); }, .workspace_symbols => { var b: std.ArrayList(u8) = .empty; @@ -548,7 +549,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "workspace/symbol", b.items, deadline)) orelse return; for (items(result)) |sym| { const loc = get(sym, "location") orelse continue; - emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); + try emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); } }, .diagnostics => try diagnostics(c, arena, &cx, uri.items, deadline), @@ -576,7 +577,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "textDocument/codeAction", b.items, deadline)) orelse return; for (items(result)) |ca| { const title = str(get(ca, "title")) orelse continue; - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); cx.rows += 1; } }, @@ -601,7 +602,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io // no location of its own (unlike the ZLS backend, which points // at declarations), so the honest place is where it would be // inserted. n/N still step the list; Enter goes nowhere new. - lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); + try lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); n += 1; } }, @@ -618,30 +619,30 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io /// Goto/references result shapes: bare Location, Location[], LocationLink[]. fn locations(cx: *Cx, enc: Enc, result: std.json.Value) Err!void { if (result == .object) { - emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); + try emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); return; } for (items(result)) |loc| { if (get(loc, "targetUri")) |tu| { const r = get(loc, "targetSelectionRange") orelse get(loc, "targetRange"); - emitRange(cx, enc, str(tu) orelse continue, r, ""); + try emitRange(cx, enc, str(tu) orelse continue, r, ""); } else { - emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); + try emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); } } } /// DocumentSymbol[] nests (`children`), SymbolInformation[] is flat. -fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) void { +fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) Err!void { if (depth > 8) return; for (items(node)) |sym| { const name = str(get(sym, "name")) orelse continue; if (get(get(sym, "location"), "range")) |r| { - emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); + try emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); } else { - emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); + try emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); } - if (get(sym, "children")) |kids| walkSymbols(cx, enc, uri, kids, depth + 1); + if (get(sym, "children")) |kids| try walkSymbols(cx, enc, uri, kids, depth + 1); } } @@ -655,7 +656,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea try jstr(&b, arena, uri); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/diagnostic", b.items, deadline)) orelse return; - for (items(get(result, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(result, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); return; } var stale = true; @@ -664,7 +665,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea break; }; if (stale) waitFresh(c, uri, nowMs() + diag_ms); - renderStore(c, arena, cx, uri); + try renderStore(c, arena, cx, uri); } fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i64) Err!void { @@ -672,15 +673,15 @@ fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i const result = (try call(c, arena, "workspace/diagnostic", "\"previousResultIds\":[]", deadline)) orelse return; for (items(get(result, "items"))) |per| { const uri = str(get(per, "uri")) orelse continue; - for (items(get(per, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(per, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); } return; } - renderStore(c, arena, cx, null); + try renderStore(c, arena, cx, null); } /// One diagnostic row: `severity: message`, at the diagnostic's own range. -fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) void { +fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) Err!void { const sev = num(get(dg, "severity")) orelse 1; const label: []const u8 = switch (sev) { 1 => "error", @@ -688,15 +689,15 @@ fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.m 3 => "info", else => "hint", }; - const msg = std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }) catch return; - emitRange(cx, enc, uri, get(dg, "range"), msg); + const msg = try std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }); + try emitRange(cx, enc, uri, get(dg, "range"), msg); } -fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) void { +fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) Err!void { for (c.diags.items) |d| { if (only_uri) |u| if (!std.mem.eql(u8, d.uri, u)) continue; const v = std.json.parseFromSliceLeaky(std.json.Value, arena, d.body, .{}) catch continue; - for (items(get(get(v, "params"), "diagnostics"))) |dg| emitDiag(cx, c.caps.enc, d.uri, dg, arena); + for (items(get(get(v, "params"), "diagnostics"))) |dg| try emitDiag(cx, c.caps.enc, d.uri, dg, arena); } } @@ -740,7 +741,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } } else if (get(result, "changes")) |ch| if (ch == .object) { @@ -753,7 +754,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } }; @@ -762,13 +763,13 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, // the preview needs the self-file rows too — the point is the full map for (edits.items) |ed| { const lc = lsp.lineCol(src, ed.start); - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); cx.rows += 1; } return; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// TextEdit[] from formatting is by definition about the current document: @@ -783,7 +784,7 @@ fn formatEdits(cx: *Cx, enc: Enc, result: std.json.Value, src: []const u8) Err!v edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// One would-be buffer mutation, on its way to an `@put` record. @@ -842,14 +843,14 @@ fn hierarchy(c: *Conn, si: usize, arena: std.mem.Allocator, cx: *Cx, uri: []cons const name = str(get(from, "name")) orelse ""; const ranges = items(get(entry, "fromRanges")); if (ranges.len == 0) { - emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); - } else for (ranges) |r| emitRange(cx, c.caps.enc, fu, r, name); + try emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); + } else for (ranges) |r| try emitRange(cx, c.caps.enc, fu, r, name); }, .outgoing => { const to = get(entry, "to") orelse continue; - emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); + try emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); }, - .supers, .subs => emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), + .supers, .subs => try emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), }; } } @@ -924,7 +925,7 @@ const Cx = struct { /// decoded, `rel`'d against the asking window), utf-16 columns become byte /// columns, and a single-line range becomes the `path:LINE:COL-ENDCOL` form a /// look SELECTS. `note` overrides the source line as the row's text. -fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) void { +fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) Err!void { if (cx.rows >= max_rows) return; const path = pathOf(cx.arena, uri) orelse return; if (path.len == 0 or path[0] != '/') return; // rows promise absolute-or-rel-from-base @@ -953,9 +954,9 @@ fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: [ // byte column; converting the exclusive utf-16 end unit yields the // exclusive byte column, which is the same number. const end_col = colBytes(lntext, r.ec, enc); - lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); + try lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); } else { - lsp.row(cx.out, shown, r.sl, col, rowtext); + try lsp.row(cx.out, shown, r.sl, col, rowtext); } cx.rows += 1; } @@ -1050,36 +1051,12 @@ fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 { return std.mem.trim(u8, buf.items, " "); } -/// Close-on-exec by fcntl, the darwin route. Same three lines as fuse.zig's -/// and nested.zig's, and here for the same reason they have their own: this -/// file imports neither. fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } -/// The client's transport: an AF_UNIX stream pair with both ends close-on-exec -/// and, on darwin, the parent end opted out of SIGPIPE. False if the host -/// refused, which is a dead server and not a dead editor. -/// -/// A named function rather than nine lines inside `ensure` because the one -/// thing it encodes is a PLATFORM LIE, and a test has to be able to call -/// exactly what the spawn calls. SOCK_CLOEXEC is a LINUX flag; zig spells -/// `SOCK.CLOEXEC` for darwin too — as 0x10000000, with "does not exist on -/// darwin but is used in std.net" in the comment beside it — and darwin's -/// socketpair(2) validates `type` strictly, so asking for it there returns -/// EPROTONOSUPPORT. Every server spawn on macOS failed on that line, before -/// the fork: no binary probe, no handshake, no message row, just `NoServer` in -/// 100µs from a client that had never once run on the platform it was written -/// on. The end-to-end suite that would have caught it (test/snapshots/ -/// lsp-client.snap) only ever runs against the linux target, where the flag is -/// real. fuse.zig and nested.zig already took the plain-socket-plus-fcntl -/// route; this was the one caller that did not. -/// -/// THE WINDOW THIS LEAVES, the same one host_io.zig states for the pty master: -/// fcntl after socketpair is not atomic, so another thread that forks and -/// execs in between inherits both ends. Linux closes it with the flag; darwin -/// has no socketpair that takes one. +// Darwin needs fcntl after socketpair; another concurrent fork can inherit the pair in that window. fn transportPair(sv: *[2]libc.fd_t) bool { const sock_type = if (comptime builtin.os.tag.isDarwin()) libc.SOCK.STREAM @@ -2065,6 +2042,35 @@ fn coord(v: ?std.json.Value) ?u32 { // ----------------------------------------------------------------- tests +test "LSP client rename and format propagate incomplete edit encoding" { + const gpa = std.testing.allocator; + const uri = "file:///file.c"; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"changes":{"file:///file.c":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"A%\n"},{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"B"}]}} + , .{}); + defer parsed.deinit(); + const expected = "@put 0 3 A%25%0A\n@put 4 7 B\n"; + for ([_]bool{ true, false }) |rename| { + var buffer: [128]u8 = undefined; + for (0..expected.len + 1) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + var cx: Cx = .{ .arena = arena.allocator(), .base = "/", .cur_path = "/file.c", .cur_src = "abc xyz", .out = &out }; + const result = if (rename) + renameEdits(&cx, .utf8, uri, parsed.value, cx.cur_src) + else + formatEdits(&cx, .utf8, get(get(parsed.value, "changes"), uri).?, cx.cur_src); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } + } +} + test "frameNext distinguishes incomplete, valid and poison frames" { try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n")); try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n\r\nhel")); diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index fb9804e6..47e6a165 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -189,47 +189,41 @@ const Trace = struct { } }; -/// The seam's contract: never fail, never panic, no rows is a legal answer. -/// Every error path in here — OOM, a cancelled io, a file that vanished — -/// collapses to "appended nothing", which the core already treats as "no -/// result". There is deliberately no error rendering. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { // status reads the log; recording it would push a real query out of a // 24-entry ring every time you looked at it. - if (req.kind == .status) return status(arena, req, out) catch {}; + if (req.kind == .status) return status(arena, req, out); - // Rows land in bounded scratch storage first, for two reasons a plain - // Writer cannot serve: the log wants an exact row count, and `explain` - // throws the rows away and prints the narration in their place. A query - // that exceeds the row budget's byte allowance keeps its ordered prefix. - const scratch_buf = gpa.alloc(u8, max_output_bytes) catch return; + const scratch_buf = try gpa.alloc(u8, max_output_bytes); defer gpa.free(scratch_buf); var scratch: std.Io.Writer = .fixed(scratch_buf); var tr: Trace = .{ .on = req.kind == .explain }; const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; run(gpa, arena, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(req, us, rows, err_name); if (req.kind != .explain) { - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); return; } - // the question was never "where is it", it was "what did you do" - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.written()) catch {}; + }); + try out.writeAll(tr.written()); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { @@ -518,7 +512,7 @@ fn rowForToken(arena: std.mem.Allocator, base: []const u8, th: Analyser.TokenWit if (th.token >= tree.tokens.len) return; const r = offsets.tokenToRange(tree, th.token, enc); const path = lsp.rel(base, th.handle.uri.toFsPath(arena) catch return); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lineAt(tree.source, r.start.line)); } // ---------------------------------------------------------------- goto @@ -627,7 +621,7 @@ fn goto( // knows the graph and baked it in; consult that. if (moduleRoot(str)) |path| { tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); - lsp.row(out, lsp.rel(base, path), 0, 0, str); + try lsp.row(out, lsp.rel(base, path), 0, 0, str); return; } tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); @@ -635,8 +629,8 @@ fn goto( tr.note(" OWN internal module names need the build graph we do not run.", .{}); return; }, - .one => |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), - .many => |us| for (us) |u| lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), + .one => |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch return), 0, 0, str), + .many => |us| for (us) |u| try lsp.row(out, lsp.rel(base, u.toFsPath(arena) catch continue), 0, 0, str), } return; }, @@ -673,7 +667,7 @@ fn goto( const t2 = &nd.handle.tree; const rr = offsets.nodeToRange(t2, nd.node, enc); const path = lsp.rel(base, nd.handle.uri.toFsPath(arena) catch continue); - lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); + try lsp.spanRow(out, path, rr.start.line, rr.start.character, rr.end.line, rr.end.character, lineAt(t2.source, rr.start.line)); }, } } @@ -973,7 +967,7 @@ fn completion( r.start.character -= @intCast(pad); if (r.end.line == r.start.line) r.end.character -= @intCast(pad); } - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, text); n += 1; } } @@ -999,7 +993,7 @@ fn hover( var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |ln| { if (std.mem.startsWith(u8, ln, "```")) continue; - out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; + try out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}); } } @@ -1043,7 +1037,7 @@ fn emitSymbols( try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) else name; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, text); if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); } } @@ -1091,7 +1085,7 @@ fn filterSymbols( try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); if (containsIgnoreCase(s.name, needle)) { n.* += 1; - lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); + try lsp.spanRow(out, path, s.selectionRange.start.line, s.selectionRange.start.character, s.selectionRange.end.line, s.selectionRange.end.character, name); } if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); } @@ -1155,19 +1149,23 @@ fn references( const path = if (new_name == null) lsp.rel(base, handle.uri.toFsPath(arena) catch return) else ""; var n: usize = 0; for (0..tree.tokens.len) |i| { - if (n >= max_rows) return; + if (new_name == null and n >= max_rows) return; const tok: Ast.TokenIndex = @intCast(i); if (tree.tokenTag(tok) != .identifier) continue; if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; const at = tree.tokenStart(tok); - const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; + const d = (declAt(arena, analyser, handle, at) catch |err| { + if (new_name != null) return err; + continue; + }) orelse continue; if (!d.eql(target)) continue; + if (n >= max_rows) return error.TooManyEdits; n += 1; if (new_name != null) { - lsp.edit(out, at, at + want.len); + try lsp.edit(out, at, at + want.len); } else { const r = offsets.tokenToRange(tree, tok, enc); - lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); + try lsp.spanRow(out, path, r.start.line, r.start.character, r.end.line, r.end.character, lines.?.line(r.start.line)); } } } @@ -1216,7 +1214,7 @@ fn diagnostics( out: *std.Io.Writer, ) !void { const n = try treeDiagnostics(gpa, arena, path, tree, out); - if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics"); + if (n == 0) try lsp.row(out, path, 0, 0, "no diagnostics"); } /// `zig ast-check`, in this process. ZLS spawns the compiler for this when it @@ -1258,7 +1256,7 @@ fn treeDiagnostics( const at = tree.tokenStart(e.token); const lc = lsp.lineCol(tree.source, at); n += 1; - lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ + try lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ w.buffered(), std.mem.trim(u8, lines.line(lc.line), " \t"), })); } @@ -1274,7 +1272,7 @@ fn treeDiagnostics( if (em.src_loc == .none) continue; const sl = bundle.getSourceLocation(em.src_loc); n += 1; - lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ + try lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ bundle.nullTerminatedString(em.msg), })); } @@ -1298,10 +1296,10 @@ fn workspaceDiagnostics( const src = readFileZ(arena, io, path) catch continue; var tree: Ast = Ast.parse(arena, src, .zig) catch continue; defer tree.deinit(arena); - total += treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out) catch continue; + total += try treeDiagnostics(gpa, arena, lsp.rel(req.root, path), &tree, out); if (total >= max_rows) return; } - if (total == 0) lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); + if (total == 0) try lsp.row(out, lsp.rel(req.root, req.path), 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.len})); } // ----------------------------------------------------------- code actions @@ -1323,7 +1321,7 @@ fn codeActions( ) !void { const tree = &handle.tree; if (tree.errors.len != 0) { - out.print("no code actions: file does not parse\n", .{}) catch {}; + try out.print("no code actions: file does not parse\n", .{}); return; } var bundle = try astCheck(gpa, "", tree); @@ -1345,9 +1343,9 @@ fn codeActions( builder.generateCodeActionsInRange(at) catch {}; for (builder.actions.items[0..@min(builder.actions.items.len, max_rows)]) |a| { - out.print("{s}\n", .{a.title}) catch return; + try out.print("{s}\n", .{a.title}); } - if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {}; + if (builder.actions.items.len == 0) try out.print("no code actions\n", .{}); } // ---------------------------------------------------------------- format @@ -1362,19 +1360,19 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi var tree: Ast = try .parse(arena, req.source, .zig); defer tree.deinit(arena); if (tree.errors.len != 0) { - lsp.row(out, path, 0, 0, "cannot format: file does not parse"); + try lsp.row(out, path, 0, 0, "cannot format: file does not parse"); return; } var count_buf: [4096]u8 = undefined; var counting: std.Io.Writer.Discarding = .init(&count_buf); - tree.render(arena, &counting.writer, .{}) catch return; + try tree.render(arena, &counting.writer, .{}); const size = std.math.cast(usize, counting.fullCount()) orelse return; const render_buf = try arena.alloc(u8, size); var w: std.Io.Writer = .fixed(render_buf); - tree.render(arena, &w, .{}) catch return; + try tree.render(arena, &w, .{}); const formatted = w.buffered(); if (std.mem.eql(u8, formatted, req.source)) { - lsp.row(out, path, 0, 0, "already formatted"); + try lsp.row(out, path, 0, 0, "already formatted"); return; } // one record, spanning only what changed: the common prefix and suffix @@ -1388,7 +1386,7 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi src_hi -= 1; fmt_hi -= 1; } - lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); + try lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]); } // ------------------------------------------------------------------ files @@ -1426,3 +1424,53 @@ fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8) ![]co fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); } + +test "LSP ZLS format propagates output failure and preserves successful encoding" { + const gpa = std.testing.allocator; + const req: lsp.Req = .{ .kind = .format, .path = "/file.zig", .source = "const value=1;\n", .offset = 6 }; + const expected = "@put 11 12 %20=%20\n"; + var buffer: [128]u8 = undefined; + for ([_]usize{ 0, expected.len - 1, expected.len }) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + const result = query(gpa, arena.allocator(), req, &out); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } +} + +test "LSP ZLS rename refuses a partial edit set beyond its row budget" { + const gpa = std.testing.allocator; + for ([_]usize{ max_rows - 1, max_rows }) |references_count| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var source: std.Io.Writer.Allocating = .init(gpa); + defer source.deinit(); + try source.writer.writeAll("const value: u32 = 1;\nfn use() void {\n"); + for (0..references_count) |_| try source.writer.writeAll(" _ = value;\n"); + try source.writer.writeAll("}\n"); + const text = try gpa.dupeZ(u8, source.written()); + defer gpa.free(text); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + const result = query(gpa, arena.allocator(), .{ + .kind = .rename, + .path = "/rename.zig", + .source = text, + .offset = 6, + .arg = "renamed", + }, &out.writer); + if (references_count < max_rows) { + try result; + try std.testing.expectEqual(max_rows, std.mem.count(u8, out.written(), "@edit ")); + } else { + try std.testing.expectError(error.TooManyEdits, result); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + } + } +} diff --git a/src/lsp_host.zig b/src/lsp_host.zig deleted file mode 100644 index 803beb31..00000000 --- a/src/lsp_host.zig +++ /dev/null @@ -1,206 +0,0 @@ -//! Turning the core's `lsp` effect into work on a thread, and its rows back -//! into something a loop can deliver. Native-shell side, like host_io.zig and -//! shell_bin.zig, and here for the reason those are: all three native shells -//! need it and none of them needs a different one. -//! -//! It was two copies before it was this. tty.zig had it inline and gui.zig had -//! it again with `tty.zig's LspJob, and copied for the same reason` written -//! over the top — identical down to the comment about a pane with no file. The -//! AppKit shell had NEITHER, so its vtable left `pull_lsp` null, the core -//! answered its own requests with no rows, and every language query did nothing -//! at all in the shell most people run. None of that looked like a missing -//! feature from the outside: `gd` just moved no cursor. A third copy is what -//! this module exists instead of. -//! -//! What is genuinely per-host stays per-host, and it is small: which allocator, -//! how a finished job reaches the loop (a mutex queue, a vaxis event, an inbox -//! plus a wakeup), and what bounds the in-flight set (a refcount to join at -//! teardown, or one future to cancel). What is NOT per-host is everything -//! below: the core goes on editing the moment the effect is drained, so every -//! byte the backend may read has to be COPIED first, and getting that ladder -//! subtly different in three places is how one shell reads freed text one -//! keystroke later. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const host_api = @import("host.zig"); - -/// One language query, owned by the worker that runs it. -pub const Job = struct { - id: u32, - kind: pardes.lsp.Kind, - offset: u32, - path: []u8, - source: [:0]u8, - arg: []u8, - root: []u8, - - pub fn free(job: *Job, gpa: std.mem.Allocator) void { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.free(job.root); - gpa.destroy(job); - } -}; - -/// Copy the query out of the core. Null when the pane is gone or an allocation -/// failed, and nothing leaks on either path — the ladder frees exactly what it -/// had managed to take. -/// -/// A pane with no file still asks `status`: that query is about the BACKEND, -/// not the buffer. Empty path and source then, and the root comes off the -/// pane's cwd so a bare terminal still reports which servers it would reach. -pub fn snapshot(gpa: std.mem.Allocator, core: *const pardes.Pardes, req: host_api.LspRequest) ?*Job { - const pane = core.panes[req.pane] orelse return null; - const file = pane.file; - const job = gpa.create(Job) catch return null; - job.* = .{ - .id = req.id, - .kind = req.kind, - .offset = req.offset, - .path = gpa.dupe(u8, if (file) |f| f.path else "") catch { - gpa.destroy(job); - return null; - }, - .source = gpa.dupeZ(u8, if (file) |f| f.content else "") catch { - gpa.free(job.path); - gpa.destroy(job); - return null; - }, - .arg = gpa.dupe(u8, req.arg) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.destroy(job); - return null; - }, - .root = gpa.dupe(u8, if (file) |f| - (std.fs.path.dirname(f.path) orelse "/") - else - pane.cwdSlice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.destroy(job); - return null; - }, - }; - return job; -} - -/// What a host does with finished rows. It TAKES OWNERSHIP of `rows`, which -/// were allocated with the same allocator the job was. -pub const Deliver = *const fn (ctx: ?*anyopaque, id: u32, rows: []u8) void; - -/// Run `job` to completion and hand its rows to `deliver`. Consumes the job -/// either way. -/// -/// This is the whole async execution model, and it is the one every shell -/// already uses for its pty reader: do the slow thing off the loop, hand the -/// result over as an event, let the core stay a state machine that never -/// blocks. The shell owns the result buffer; the backend only writes into it. -pub fn work(gpa: std.mem.Allocator, job: *Job, ctx: ?*anyopaque, deliver: Deliver) void { - defer job.free(gpa); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - pardes.lsp.query(gpa, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - // Duped out of the writer: `deliver` outlives this frame and the writer - // does not. A failed dupe drops the answer, which the core survives — the - // request times out into no rows, exactly as an empty answer would. - const rows = gpa.dupe(u8, out.written()) catch return; - deliver(ctx, job.id, rows); -} - -test "a snapshot owns every byte the backend will read" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - var needle: [6]u8 = "needle".*; - const job = snapshot(gpa, core, .{ - .id = 7, - .kind = .status, - .pane = @intCast(core.active), - .offset = 0, - .arg = &needle, - }) orelse return error.SnapshotFailed; - defer job.free(gpa); - - try std.testing.expectEqual(@as(u32, 7), job.id); - try std.testing.expectEqual(pardes.lsp.Kind.status, job.kind); - // `arg` is the caller's buffer on the way in and the job's own bytes on the - // way out. THIS is the property the whole ladder exists for: the core reuses - // that buffer for the next builtin's argument the moment the effect drains. - try std.testing.expectEqualStrings("needle", job.arg); - try std.testing.expect(job.arg.ptr != &needle); - // A terminal pane has no file and the query still has to be answerable: - // empty path, a NUL-terminated empty source, and the pane's own cwd as the - // root so a bare terminal still reports which servers it would reach. The - // cwd may legitimately be empty in a core that has never spawned a shell; - // what matters is that the job OWNS it rather than borrowing it. - try std.testing.expectEqualStrings("", job.path); - try std.testing.expectEqual(@as(usize, 0), job.source.len); - try std.testing.expectEqual(@as(u8, 0), job.source[0]); - const pane = core.panes[core.active].?; - try std.testing.expectEqualStrings(pane.cwdSlice(), job.root); - if (job.root.len > 0) try std.testing.expect(job.root.ptr != pane.cwdSlice().ptr); -} - -test "a pane that is gone yields no job rather than a null deref" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - // The effect is drained after the core has moved on, so the pane it names - // may already have been deleted. Every shell open-coded this check. - const empty = for (core.panes, 0..) |slot, id| { - if (slot == null) break @as(u8, @intCast(id)); - } else return error.NoEmptyPane; - try std.testing.expect(snapshot(gpa, core, .{ - .id = 1, - .kind = .definition, - .pane = empty, - .offset = 0, - .arg = "", - }) == null); -} - -test "work consumes the job and hands its rows to the sink" { - const gpa = std.testing.allocator; - const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); - defer core.deinit(); - - const Sink = struct { - var seen_id: u32 = 0; - var seen_rows: ?[]u8 = null; - fn take(_: ?*anyopaque, id: u32, rows: []u8) void { - seen_id = id; - seen_rows = rows; - } - }; - Sink.seen_id = 0; - Sink.seen_rows = null; - - const job = snapshot(gpa, core, .{ - .id = 42, - .kind = .status, - .pane = @intCast(core.active), - .offset = 0, - .arg = "", - }) orelse return error.SnapshotFailed; - work(gpa, job, null, Sink.take); - - // `status` is the one kind that answers with no file and no cursor, which - // is what makes it assertable here without a language server on the box. - try std.testing.expectEqual(@as(u32, 42), Sink.seen_id); - const rows = Sink.seen_rows orelse return error.SinkNeverCalled; - defer gpa.free(rows); -} diff --git a/src/macos.zig b/src/macos.zig index ddab78e9..36c70f86 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1,23 +1,5 @@ -//! libpardes — the static library the native macOS app links against. -//! -//! The split, which is the whole design: Zig keeps the core, the ptys, every -//! effect and the worker threads; Swift owns NSApplication, the window, input -//! translation and drawing. src/macos/pardes.h is the contract between them and -//! docs/macos.md argues for the shape. -//! -//! This is deliberately src/web.zig's boundary with the wasm removed. Both -//! hosts are the same animal — someone else owns the clock, feeds events in -//! through flat functions and reads one packed cell buffer out — and the -//! browser already proved the shape works. The one real divergence is that the -//! browser has no processes, so it forwards every effect to JavaScript, whereas -//! forkpty is right here and this file performs them. -//! -//! Everything below is main-thread only. The single exception is the `wakeup` -//! callback, which a pty reader task calls; the host's job is to hop to the -//! main thread and call pardes_tick. -//! -//! The Zig half is ordinary POSIX and builds/tests on Linux — see the dev-loop -//! section of docs/macos.md. Only the Swift app needs a Mac. +const filesystem = @import("fs.zig"); +const ninep_io = @import("9p_io.zig"); const std = @import("std"); const builtin = @import("builtin"); @@ -25,29 +7,16 @@ const posix = std.posix; const libc = std.c; const pardes = @import("pardes.zig"); const look = @import("look.zig"); -const shell_bin = @import("shell_bin.zig"); -const message = @import("message.zig"); -const nested = @import("nested.zig"); -const panel_animation = @import("panel_animation.zig"); +const message = pardes.Pardes.Message; +const layout = @import("layout.zig"); const file_watch = @import("file_watch.zig"); -/// The geometry types the pixel-attachment ABI carries. Behind the same -/// comptime gate the placements themselves are: a build without MuPDF emits no -/// attachments, so nothing here is analysed. const image = if (pardes.pdf_enabled) @import("image.zig") else struct {}; -const user_config = @import("user_config.zig"); const host_io = @import("host_io.zig"); const fonts = @import("fonts.zig"); // the shared fallback preference order -const lsp_host = @import("lsp_host.zig"); // the shared snapshot + worker body -const host_api = @import("host.zig"); // LspRequest and the vtable's own types const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout const selection_pipe = @import("selection_pipe.zig"); // Job, runJob and Tasks const crash = @import("crash.zig"); -/// This file is the ROOT of the macOS build (build.zig: the AppKit shell is a -/// library whose host owns main()), so `std.builtin.panic` resolves here and -/// not in src/main.zig — a handler written only there would never run in the -/// app, which is the shell with the least useful stderr of the four. No -/// terminal to restore either, which is the rest of what main.zig's does. pub const panic = std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { crash.record(msg); @@ -57,9 +26,6 @@ pub const panic = std.debug.FullPanic(struct { extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -/// Implemented by FileWatcher.swift in the app and e2e host. Zig-only unit -/// tests have no AppKit runloop and compile this call away; the shipped static -/// library leaves the symbol for its Swift executable to satisfy directly. extern "c" fn pardes_host_watch_file( pane: u8, generation: u32, @@ -72,14 +38,8 @@ fn hostWatchFile(pane: u8, generation: u32, path: ?[*]const u8, path_len: usize) pardes_host_watch_file(pane, generation, path, path_len); } -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same -// constant the tty and gui shells spell for the same reason. const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); -// A library linked into an AppKit process has no terminal to garble, but it -// does share the app's stderr with Console.app. Same filter as src/main.zig: -// ghostty-vt narrates every unimplemented escape a child writes, and nobody -// wants that in a crash report. PARDES_LOG=1 gets the real logger back. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( @@ -94,15 +54,6 @@ fn logFn( const log = std.log.scoped(.macos); -// ---------------------------------------------------------------- boundary - -/// Sync with: pardes_cell_s. The identical encoding is spelled a second time -/// for the browser as WebCell in src/web.zig. -/// -/// ponytail: two copies of a fifteen-line pure encoder, not a shared module. -/// The web ABI is snapshot-tested through a headless Chrome that does not run -/// here, so extracting it would refactor a backend I cannot exercise to save -/// thirty lines. Merge them the day a third host wants the same bytes. pub const Cell = extern struct { text: [8]u8, fg: u32, @@ -112,40 +63,21 @@ pub const Cell = extern struct { flags: u8, }; -/// Sync with: pardes_scene_s. Persistent full-window effects share one host -/// postprocess, so one plain snapshot carries both its switches and clock. pub const Scene = extern struct { flags: u32 = 0, time_seconds: f32 = 0, frame: u32 = 0, }; -/// Sync with pardes_panel_{box,track}_s. The core's backend-neutral Track is -/// already an extern POD record, so the native boundary can publish it without -/// translating the easing vocabulary into a second representation. -pub const PanelBox = panel_animation.Box; -pub const PanelTrack = panel_animation.Track; - -/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an -/// image pane's pixels — and where on the grid it goes. -/// -/// Geometry travels in PHYSICAL PIXELS, because that is the space the core -/// already computed it in (pardes_resize hands it the physical cell). `cell_x` -/// and `cell_y` are the pane BODY's origin in cells and the only thing the -/// host has to multiply out; `dst` is relative to that origin, and `src` is -/// the crop of the raster to take. The core has already clipped both to the -/// viewport, which is what lets a host draw a continuous-scroll page without -/// inventing an overflow clip of its own. +pub const PanelBox = layout.Box; +pub const PanelTrack = layout.Track; + pub const Image = extern struct { - /// pane lifetime, page and raster generation: together the cache key. A - /// host keeps its decoded texture while all three hold still, and `fit`, - /// panning and scrolling deliberately do not move them. serial: u32, page: u32, revision: u32, cell_x: u16, cell_y: u16, - /// the body this attachment may not paint outside of, in cells cell_w: u16, cell_h: u16, dst_x: u32, @@ -156,18 +88,12 @@ pub const Image = extern struct { src_y: u32, src_w: u32, src_h: u32, - /// subpixel vertical displacement a proportional wheel kept offset_y: f32, iw: u32, ih: u32, - /// iw * ih * 4 bytes, RGBA8. Borrowed until the next pardes_frame. rgba: [*]const u8, }; -/// Sync with: pardes_runtime_s. Three callbacks, because everything else the -/// core asks for it already does itself — it owns the ptys, and look.openLink -/// hands URLs to /usr/bin/open. All optional at the ABI level: a host that -/// passes null simply does without, rather than trapping inside the library. pub const Runtime = extern struct { userdata: ?*anyopaque = null, wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null, @@ -182,30 +108,14 @@ const cell_flag_tagline: u8 = 2; const scene_flag_crt: u32 = 1 << 0; const scene_flag_ripple: u32 = 1 << 1; const scene_flag_glitch: u32 = 1 << 2; -/// The nominal display cadence the SHADER's `frame` field is expressed in. It -/// is a unit of that field and nothing else now: the animation clock below is -/// driven by measured elapsed time, not by counting callbacks. const scene_frame_hz: u32 = 60; -/// The scene clock wraps here so `time_seconds` never grows large enough for an -/// f32 to lose sub-millisecond resolution. 4096 seconds, the same span the old -/// 4096-frames-per-hz counter covered. const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; -/// The most elapsed time one tick may cash in. A window that was occluded, a -/// laptop that slept or a debugger breakpoint all produce an enormous dt, and -/// spending it would fast-forward an animation instead of resuming it. -const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns; -/// FileWatcher.swift keys sources by an opaque u8. Pane ids occupy 0..15; -/// the next value is the one process-global ThemeFile source. +const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 1; -// ---------------------------------------------------------------- state - -/// One pty, and the task draining it. `gen` is the per-slot spawn generation: -/// the core reuses pane ids and has no close effect, so a respawned slot must -/// ignore the previous shell's late bytes rather than feed them to the new one. const Pty = struct { file: std.Io.File, pid: posix.pid_t, @@ -213,18 +123,11 @@ const Pty = struct { reader: std.Io.Future(anyerror!void), }; -/// Main-thread ownership for the host's per-pane vnode sources. A path is -/// copied rather than borrowed from Pane: a queued callback may outlive the -/// effect which replaced that pane slot, and exact path equality is the final -/// guard before any bytes reach the core. const WatchedFile = struct { path: []u8, serial: u32, generation_on_disk: file_watch.Generation, generation: u32, - /// One self-scheduled reconciliation after a transient read/reopen race. - /// A real host edge replenishes it; a malformed stable file therefore - /// tries twice and then sleeps rather than becoming an idle busy loop. retries_left: u8 = 1, }; @@ -247,9 +150,6 @@ const FileWatches = struct { serial: u32, generation_on_disk: file_watch.Generation, ) !u32 { - // Allocate first. If memory is tight, the caller can explicitly stop - // the old source; silently retaining a watch for a reused pane would be - // worse than having no watch at all. const owned = try gpa.dupe(u8, path); if (watches.entries[pane]) |old| gpa.free(old.path); const generation = watches.nextGeneration(pane); @@ -270,10 +170,6 @@ const FileWatches = struct { return watches.nextGeneration(pane); } - /// Coalesce any number of vnode events into one main-thread re-read. - /// The Swift side already debounces a burst; this bit is the second, cheap - /// edge which prevents two queued callbacks from applying one snapshot - /// twice. A stale generation can never dirty a reused pane slot. fn notify(watches: *FileWatches, pane: u8, generation: u32) bool { const watched = if (watches.entries[pane]) |*entry| entry else return false; if (watched.generation != generation) return false; @@ -350,8 +246,6 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks try std.testing.expect(watches.retry(3, second)); try std.testing.expect(!watches.retry(3, second)); try std.testing.expect(watches.takeDirty(3)); - // A stable malformed file cannot self-wake forever, but a later real vnode - // edge replenishes exactly one retry for the new external transaction. try std.testing.expect(watches.notify(3, second)); try std.testing.expectEqual(@as(u8, 1), watches.entries[3].?.retries_left); try std.testing.expect(watches.takeDirty(3)); @@ -373,10 +267,87 @@ test "mac file watch generations own paths, coalesce, and reject stale callbacks const file_watcher_swift = @embedFile("macos/Sources/FileWatcher.swift"); +test "mac queued results never cancel a newer LSP request" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(0, .status, ""); + const old_id = core.lsp_wait.?.id; + core.lspRequest(0, .status, ""); + const current_id = core.lsp_wait.?.id; + st.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task != null); + try std.testing.expectEqual(current_id, st.lsp_task.?.id); + try std.testing.expectEqual(current_id, core.lsp_wait.?.id); + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(core.lsp_wait == null); +} + +test "mac worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var st: State = .{ + .gpa = failing.allocator(), + .threaded = undefined, + .io = failing_io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.config_arena.deinit(); + defer st.inbox.close(gpa, std.testing.io); + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + lspRequest(&st, req); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + pipeRequest(&st, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expect(st.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + } +} + test "mac host watcher covers file and directory vnode events, debounce, and generation callback" { - // Linux cannot compile AppKit/Dispatch Swift. Keep the critical architecture - // check reachable there: atomic saves need the parent, in-place writes need - // a rearmed file source, and all mutation returns through the generation ABI. try std.testing.expect(std.mem.indexOf( u8, file_watcher_swift, @@ -399,33 +370,18 @@ test "mac host watcher covers file and directory vnode events, debounce, and gen ) != null); } -/// What a reader task hands the main thread. `gen` travels with the message so -/// a shell that was replaced while its read was in flight cannot have its -/// stragglers parsed into the pty that took its slot. const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32 }, - /// One `Look ` line from a pardes launched inside this one. Arrives - /// on the listener thread; runs, like everything else, on the main one. - command: []u8, - /// A language query finished on a worker; `rows` are gpa-owned. NOT lossy: - /// the core is holding a request id open for exactly this, and dropping it - /// leaves `lsp_wait` armed and every later query dead. - lsp_done: struct { id: u32, rows: []u8 }, - /// Unsolicited server state — "rust-analyzer indexing 45%" — for the - /// transient message row. Periodic news, so it IS lossy: a dropped line is - /// repriced by the next one. + lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// A `|` filter finished on a worker. NOT lossy for the same reason - /// `lsp_done` is not: the core is holding a request id open for it. pipe: selection_pipe.Response, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, - .command => |c| gpa.free(c), - .lsp_done => |d| gpa.free(d.rows), + .lsp_done => |d| if (d.rows) |rows| gpa.free(rows), .lsp_status => |t| gpa.free(t), .pipe => |r| { var response = r; @@ -435,8 +391,8 @@ const Msg = union(enum) { } }; - const inbox_capacity = 512; +const inbox_output_limit = inbox_capacity - pardes.MAX_PANES - selection_pipe.Tasks.capacity - 2; const MessageBatch = struct { items: [inbox_capacity]Msg = undefined, @@ -448,202 +404,272 @@ const MessageBatch = struct { }; const Inbox = struct { - mutex: std.atomic.Mutex = .unlocked, + mutex: std.Io.Mutex = .init, + space: std.Io.Condition = .init, items: [inbox_capacity]Msg = undefined, - head: usize = 0, len: usize = 0, closed: bool = false, - /// Set when a wakeup has been delivered and not yet answered by a tick. wake_pending: std.atomic.Value(bool) = .init(false), - fn lock(q: *Inbox) void { - // AppKit's main thread runs at a higher QoS than reader tasks, so yield - // periodically rather than donating a full core to a preempted reader. - var spins: u8 = 0; - while (!q.mutex.tryLock()) { - spins +%= 1; - if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint(); - } - } - fn removeAt(q: *Inbox, offset: usize) Msg { - const removed = q.items[(q.head + offset) % q.items.len]; - var i = offset; - while (i + 1 < q.len) : (i += 1) - q.items[(q.head + i) % q.items.len] = q.items[(q.head + i + 1) % q.items.len]; + const removed = q.items[offset]; + std.mem.copyForwards(Msg, q.items[offset .. q.len - 1], q.items[offset + 1 .. q.len]); q.len -= 1; return removed; } - /// Pty output is lossy under sustained backpressure. EOF is structural, and - /// so is a nested `Look`: one is a reader that must be reaped, the other is - /// a launch that already exited believing it was delivered. Admit both by - /// evicting queued output. Every switch below is exhaustive on purpose — a - /// new message kind has to say which of the two it is. - fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { - q.lock(); - defer q.mutex.unlock(); + fn pushOutput(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, output: @FieldType(Msg, "output")) std.Io.Cancelable!void { + errdefer gpa.free(output.bytes); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + while (q.len >= inbox_output_limit and !q.closed) try q.space.wait(io, &q.mutex); + if (q.closed) return error.Canceled; + q.items[q.len] = .{ .output = output }; + q.len += 1; + } + + fn push(q: *Inbox, gpa: std.mem.Allocator, io: std.Io, m: Msg) void { + std.debug.assert(m != .output); + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); if (q.closed) { m.free(gpa); return; } - if (q.len == q.items.len) { - const lossy = switch (m) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }; - if (lossy) { - m.free(gpa); - return; - } + if (m != .pipe) { var offset: usize = 0; - while (offset < q.len) : (offset += 1) - if (switch (q.items[(q.head + offset) % q.items.len]) { - .output, .lsp_status => true, - .eof, .command, .lsp_done, .pipe => false, - }) break; - if (offset == q.len) return; - q.removeAt(offset).free(gpa); + while (offset < q.len) : (offset += 1) { + const old = q.items[offset]; + const replaced = switch (m) { + .eof => |end| old == .eof and old.eof.pane == end.pane, + .lsp_done => old == .lsp_done, + .lsp_status => old == .lsp_status, + .output, .pipe => unreachable, + }; + if (replaced) { + q.removeAt(offset).free(gpa); + break; + } + } } - q.items[(q.head + q.len) % q.items.len] = m; + std.debug.assert(q.len < q.items.len); + q.items[q.len] = m; q.len += 1; } - fn take(q: *Inbox) MessageBatch { - q.lock(); - defer q.mutex.unlock(); - var batch: MessageBatch = .{}; - while (q.len > 0) { - batch.items[batch.len] = q.items[q.head]; - batch.len += 1; - q.head = (q.head + 1) % q.items.len; - q.len -= 1; - } - q.head = 0; + fn take(q: *Inbox, io: std.Io) MessageBatch { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); + var batch: MessageBatch = .{ .len = q.len }; + @memcpy(batch.items[0..q.len], q.items[0..q.len]); + q.len = 0; + q.space.broadcast(io); return batch; } - fn close(q: *Inbox, gpa: std.mem.Allocator) void { - q.lock(); - defer q.mutex.unlock(); + fn close(q: *Inbox, gpa: std.mem.Allocator, io: std.Io) void { + q.mutex.lockUncancelable(io); + defer q.mutex.unlock(io); q.closed = true; - while (q.len > 0) { - q.items[q.head].free(gpa); - q.head = (q.head + 1) % q.items.len; - q.len -= 1; - } - q.head = 0; + for (q.items[0..q.len]) |msg| msg.free(gpa); + q.len = 0; + q.space.broadcast(io); } }; +test "mac inbox coalesces completions at the tail without reordering terminal output" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = std.math.maxInt(u32), .rows = try gpa.dupe(u8, "old result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "old status") }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = std.math.maxInt(u32), .bytes = try gpa.dupe(u8, "old output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = std.math.maxInt(u32) } }); + try inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 0, .bytes = try gpa.dupe(u8, "new output") }); + inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 0 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 0, .rows = try gpa.dupe(u8, "new result") } }); + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "new status") }); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 5), batch.len); + try std.testing.expectEqualStrings("old output", batch.items[0].output.bytes); + try std.testing.expectEqualStrings("new output", batch.items[1].output.bytes); + try std.testing.expectEqual(@as(u32, 0), batch.items[2].eof.gen); + try std.testing.expectEqual(@as(u32, 0), batch.items[3].lsp_done.id); + try std.testing.expectEqualStrings("new result", batch.items[3].lsp_done.rows.?); + try std.testing.expectEqualStrings("new status", batch.items[4].lsp_status); + inbox.close(gpa, std.testing.io); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "closed") } }); + try std.testing.expectEqual(@as(usize, 0), inbox.len); +} + +test "mac inbox admits every retained task completion when output fills the queue" { + const gpa = std.testing.allocator; + var inbox: Inbox = .{}; + defer inbox.close(gpa, std.testing.io); + var tasks: selection_pipe.Tasks = .{}; + defer tasks.cancelAll(std.testing.io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "output"), + }); + for (0..pardes.MAX_PANES) |pane| inbox.push(gpa, std.testing.io, .{ .eof = .{ .pane = @intCast(pane), .gen = 1 } }); + inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = 1, .rows = try gpa.dupe(u8, "result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + if (index == 0) { + const output = try gpa.dupe(u8, "filtered"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = id, .success = true, .outputs = outputs } }); + } else inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "no match") }, + } }); + } + inbox.push(gpa, std.testing.io, .{ .lsp_status = try gpa.dupe(u8, "current status") }); + try std.testing.expectEqual(inbox_capacity, inbox.len); + var batch = inbox.take(std.testing.io); + defer for (batch.slice()) |msg| msg.free(gpa); + var eof_count: usize = 0; + var lsp_count: usize = 0; + var pipe_count: usize = 0; + var output_count: usize = 0; + for (batch.slice()) |msg| switch (msg) { + .eof => eof_count += 1, + .lsp_done => lsp_count += 1, + .pipe => |result| { + pipe_count += 1; + tasks.finish(std.testing.io, result.id); + }, + .output => |output| { + output_count += 1; + try std.testing.expectEqualStrings("output", output.bytes); + }, + .lsp_status => |status| try std.testing.expectEqualStrings("current status", status), + }; + try std.testing.expectEqual(pardes.MAX_PANES, eof_count); + try std.testing.expectEqual(@as(usize, 1), lsp_count); + try std.testing.expectEqual(selection_pipe.Tasks.capacity, pipe_count); + try std.testing.expectEqual(inbox_output_limit, output_count); + try std.testing.expectEqual(@as(usize, 0), tasks.len); +} + +test "mac inbox preserves output under backpressure and wakes on take cancel and close" { + const gpa = std.testing.allocator; + const io = std.testing.io; + const Producer = struct { + fn run(q: *Inbox, done: *std.Io.Event) !void { + defer done.set(std.testing.io); + defer q.push(std.testing.allocator, std.testing.io, .{ .eof = .{ .pane = 0, .gen = 1 } }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "tail\x1b[0m"), + }); + try q.pushOutput(std.testing.allocator, std.testing.io, .{ + .pane = 0, + .gen = 1, + .bytes = try std.testing.allocator.dupe(u8, "é😀"), + }); + } + }; + for ([_]enum { take, cancel, close }{ .take, .cancel, .close }) |action| { + var inbox: Inbox = .{}; + defer inbox.close(gpa, io); + for (0..inbox_output_limit) |_| try inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "before"), + }); + var done: std.Io.Event = .unset; + var future = try io.concurrent(Producer.run, .{ &inbox, &done }); + defer future.cancel(io) catch {}; + for (0..1000) |_| { + if (inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), inbox.space.state.load(.acquire).waiters); + switch (action) { + .take => { + var before = inbox.take(io); + defer for (before.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit, before.len); + for (before.slice()) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try future.await(io); + var after = inbox.take(io); + defer for (after.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 3), after.len); + try std.testing.expectEqualStrings("tail\x1b[0m", after.items[0].output.bytes); + try std.testing.expectEqualStrings("é😀", after.items[1].output.bytes); + try std.testing.expect(after.items[2] == .eof); + }, + .cancel => { + try std.testing.expectError(error.Canceled, future.cancel(io)); + var batch = inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(inbox_output_limit + 1, batch.len); + for (batch.slice()[0..inbox_output_limit]) |msg| try std.testing.expectEqualStrings("before", msg.output.bytes); + try std.testing.expect(batch.items[inbox_output_limit] == .eof); + }, + .close => { + inbox.close(gpa, io); + try done.waitTimeout(io, .{ .duration = .{ .raw = .fromSeconds(2), .clock = .awake } }); + try std.testing.expectError(error.Canceled, future.await(io)); + try std.testing.expectEqual(@as(usize, 0), inbox.len); + }, + } + } +} + const State = struct { + ninep: ?*ninep_io.Listener = null, gpa: std.mem.Allocator, threaded: *std.Io.Threaded, io: std.Io, core: *pardes.Pardes, - /// False for the one effect drain inside pardes_init and nothing else: no - /// reader task exists yet, and the first theme file must land without a fade. started: bool = false, runtime: Runtime, cells: []Cell = &.{}, - /// Frozen canonical grid paired with an encoded change mask while a content or - /// lifecycle transition is active. Both are encoded at frame time so the - /// native renderer never borrows core-owned Cell layout across the ABI. previous_cells: []Cell = &.{}, changed_cells: []u8 = &.{}, panel_diff_len: usize = 0, frame_len: usize = 0, - /// The grid `cells` actually holds. Not read back off the core: a render - /// can move screen_w/screen_h and then fail, and a host that sized its - /// loops from those would walk off the buffer. frame_cols: u16 = 0, frame_rows: u16 = 0, - /// This frame's pixel attachments, flattened out of Surface.images. Grown - /// and reused like `cells`, and emptied by the same failure path — the - /// accessors must never describe a different frame than the cell count. images: []Image = &.{}, images_len: usize = 0, - /// This frame's panel transitions, copied out of Surface in deterministic - /// paint order: moving, opening, then frozen closing tombstones. panel_tracks: [pardes.MAX_PANES * 2]PanelTrack = undefined, panel_tracks_len: usize = 0, ptys: [pardes.MAX_PANES]?Pty = @splat(null), inbox: Inbox = .{}, - /// The single in-flight language query. ONE slot, like the tty shell's: - /// replacing it cancels the previous worker, which is right because the - /// only query anyone is waiting for is the one they just asked for. - lsp_task: ?std.Io.Future(anyerror!void) = null, - /// Filters running off the main thread. Bounded by the shared table; a full - /// one answers the request as failed rather than queueing it. + lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: selection_pipe.Tasks = .{}, file_watches: FileWatches = .{}, - /// Per-slot spawn generation, owned by the main thread. A reader carries a - /// copy in every message it posts; anything that no longer matches belongs - /// to a shell this slot has already replaced. gens: [pardes.MAX_PANES]u32 = @splat(0), - /// Sub-cell wheel distance the core has not been told about yet, one - /// accumulator per axis. The core moves a whole row or column at a time, - /// so fractional trackpad travel banks here and is spent as wheel presses - /// — see pardes_scroll. Separate axes because a diagonal drift must not - /// let one direction's residue push the other over a notch. scroll_lag: f32 = 0, scroll_lag_x: f32 = 0, - /// Degrees of trackpad rotation not yet spent as a search step — the same - /// accumulate-and-keep-the-remainder shape as scroll_lag, see pardes_rotate. rotate_lag: f32 = 0, - /// The dial's angular velocity, in degrees per second. While fingers are - /// down this is a running estimate off the event stream; when they lift it - /// becomes the fling that `coasting` spends. Zero is a dial at rest. rotate_velocity: f32 = 0, - /// When the last rotation event arrived, so the estimate above has a dt. rotate_last_ns: i128 = 0, - /// Fingers are off and the dial is still turning. Separate from a nonzero - /// velocity because during the gesture that velocity is a MEASUREMENT — - /// spending it then would double every twist under the hand making it. rotate_coasting: bool = false, - /// Real elapsed time for the persistent Core Image scene pass, in - /// nanoseconds. Input and pty pumps never spend it; pardes_animation_tick - /// is the only writer. - /// - /// TIME, not a callback count. It used to be a frame counter divided by an - /// assumed 60 Hz, and the callbacks do not arrive at 60 Hz — the pump - /// re-arms `asyncAfter(0.016)` only after the previous frame's work, so the - /// real period is 16 ms PLUS a tick, a drain and a draw. Shader time - /// therefore advanced at roughly three quarters of wall clock, unevenly, - /// which is what a scene effect looks like when it stutters. scene_ns: u64 = 0, - /// Monotonic stamp of the previous tick, and the leftover time that was not - /// yet worth a whole fixed animation step. The core's transitions count - /// FRAMES, so real elapsed time is banked here and spent in whole - /// `animation.frame_ns` steps: a late callback advances two frames instead - /// of stretching one, which is what keeps a transition's duration the same - /// on a busy machine as on an idle one. last_tick_ns: u64 = 0, tick_bank_ns: u64 = 0, - /// Panes whose shell has produced output since we last read its cwd. - /// - /// The cwd is wanted for pane tags and for resolving a relative Look, and - /// asking libproc costs a syscall per pane. Polling it on a clock spends - /// that forever to notice something that only ever changes when the shell - /// runs a command — and a shell that ran a command always writes at least - /// its next prompt. So the read is owed to output, not to time: mark here - /// on the way past and settle it once at the end of the drain, however - /// many chunks that burst arrived in. cwd_stale: [pardes.MAX_PANES]bool = @splat(false), - /// The socket a pardes launched inside this app connects to (nested.zig), - /// or -1 when it could not be bound and nested launches open their own - /// window as they always did. - sock_fd: c_int = -1, - /// Owns the bytes of the user config, which Options only borrows. config_arena: std.heap.ArenaAllocator, - /// Private prompt snippets borrowed by every child argv until exec. - prompt_rcs: shell_bin.PromptRcs, + prompt_rcs: host_io.Shell.PromptFiles, }; var state: ?State = null; -// ---------------------------------------------------------------- lifecycle - export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_int { if (state != null) return 1; // already up; deinit first initCore(runtime, cols_arg, rows_arg) catch |err| { @@ -653,17 +679,11 @@ export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_ return 0; } -/// The body is split out purely so the cleanup below is real: `errdefer` fires -/// on an error return and nothing else, so writing this inside an export that -/// returns c_int would leave every one of these as dead code — and a half-built -/// init leaks an arena, leaves zstbi pointing at a dead allocator, and (because -/// Io.Threaded installs process-wide SIGIO/SIGPIPE handlers that only its -/// deinit restores) hands those handlers permanently to the host app. fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const gpa = std.heap.smp_allocator; - const allocs = pardes.allocators.init(gpa); - errdefer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + errdefer pardes.memory.deinit(); const threaded = try gpa.create(std.Io.Threaded); errdefer gpa.destroy(threaded); @@ -676,25 +696,16 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var opts: pardes.Options = .{ .tty_only = true, - // The purpose-built 16 MiB stack-fallback buffer this host has always - // rendered out of; the core builds its per-frame Surface arena on it. .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, .tree_sitter_allocator = allocs.tree_sitter, }; - // Native shells opt into the user config, and every builtin in it must have - // run before the host can render a frame — so it is read here, before - // Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from - // libc's environ because a library has no std.process.Init to inherit one. if (captureEnv(config_arena.allocator())) |*env| { - const found = user_config.load(io, config_arena.allocator(), env); + const found = pardes.config.User.load(io, config_arena.allocator(), env); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // This host has no terminal at all, so the panic trace stderr gets goes - // to a Console.app nobody has open. `panic` above writes it beside the - // init file too, and this is where it learns the directory. if (found.dir) |d| crash.setDir(d); } @@ -707,17 +718,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { const core = try pardes.Pardes.init(allocs.pardes, opts); errdefer core.deinit(); - // This host draws pixels. Without it the core assumes a terminal that - // cannot, and a PDF pane degrades to counted page turns with nothing on - // screen at all — which is exactly what it did. The SDL shell sets the - // same flag; the tty one sets it from the terminal's kitty-graphics - // capability, because there it is a question rather than a fact. core.native_images = true; - // PATH, the bash banner and the prompt rc files, in the one order that - // works. State retains the path buffers for every later spawn and removes - // the files at app teardown. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); errdefer prompt_rcs.deinit(); state = .{ @@ -730,96 +733,38 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; - // Every capability this host has, including the tty pull the core makes at - // the Exec that cares rather than at the cwd read above. Assigned here and - // not left to `pump`, because the spawns below happen outside one. + st.ninep = ninep_io.start(gpa, core); core.host = hostFor(st); - // The real grid, delivered as an EVENT and not as Options.cols/rows: the - // core defers an integrated shell's greeting until this resize and OSC - // 133 B; the first forkpty below takes its winsize straight off the core. const cols = @max(1, cols_arg); const rows = @max(1, rows_arg); core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // The initial spawns happen before any reader task exists, mirroring the - // tty shell. Note the difference in what that buys: tty.zig runs from - // main() and really is single-threaded there, whereas this is called from - // applicationDidFinishLaunching, by which point AppKit and libdispatch - // have long since spawned threads. What keeps the fork safe is the child - // itself — chdir and execv, raw syscalls with nothing allocated between - // fork and exec — not the thread count. Ordering it this way anyway keeps - // the two backends readable side by side. while (core.nextEffect()) |effect| core.perform(effect); st.started = true; + if (st.ninep) |listener| listener.wakeThread(st, wakeNinep) catch |err| core.reportError(0, "9p wake", err); for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); - // Server-state narration onto the transient message row. Registered HERE - // and not at the `state = .{...}` assignment because the sink is called - // from the protocol client's reader threads and must not fire before the - // inbox is reachable. Without this the sink existed and nothing ever called - // it, so "rust-analyzer: indexing 45%" never appeared in this shell. pardes.lsp.setStatusSink(st, lspStatusSink); - - // Last, because it is the one thing here that publishes this process to - // the outside: nothing may connect before the core can answer. The shells - // above are already forked, which is why the listener's fd is CLOEXEC — - // an orphaned bash holding it would keep the socket bound after we quit. - st.sock_fd = nested.listen(); - if (st.sock_fd >= 0) { - const thread = std.Thread.spawn(.{}, lookServer, .{st}) catch |err| { - // Bound but unattended would be worse than never bound: every - // nested launch would connect, be believed, and vanish. - log.warn("nested Look server did not start ({t})", .{err}); - nested.unlisten(st.sock_fd); - st.sock_fd = -1; - return; - }; - thread.detach(); - } } -/// Accept `Look ` lines from pardes instances launched inside this app -/// and post them where the main thread will run them. -/// -/// A detached thread around a call that never returns, exactly like the tty -/// backend's: close(2) does not release a thread parked in accept(2), so this -/// dies with the process rather than with the socket. The window that leaves -/// is one connection accepted between the last tick and process exit posting -/// into an inbox nobody drains — the same bound the pty readers have, and a -/// self-pipe to close it would be more machinery than the window is worth. -fn lookServer(st: *State) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(st.sock_fd, &buf)) |line| { - const owned = st.gpa.dupe(u8, line) catch continue; - st.inbox.push(st.gpa, .{ .command = owned }); - wake(st); - } +fn wakeNinep(ctx: ?*anyopaque) void { + const st: *State = @ptrCast(@alignCast(ctx orelse return)); + wake(st); } export fn pardes_deinit() void { const st = &(state orelse return); - // Before anything else: it is the only fd another process can reach us - // through, and unlinking the file is what stops the next launch from - // connecting to a session that is halfway through tearing itself down. - // The thread parked in accept(2) is not released by this and dies with - // the process, which is what its detach() already said. - nested.unlisten(st.sock_fd); - st.sock_fd = -1; - // The protocol client's reader threads call the sink, and the State it is - // handed is about to become null: unregister before the inbox goes away, - // and cancel the one query that may still be running against it. + if (st.ninep) |listener| { + listener.deinit(st.gpa); + st.ninep = null; + } pardes.lsp.setStatusSink(null, null); if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + t.future.cancel(st.io) catch {}; st.lsp_task = null; } - // ...and every filter still running against it. A future nobody cancels is - // a thread writing into a State that is about to be null. st.pipe_tasks.cancelAll(st.io); - // Cancel host directory sources while their generation table still exists. - // A debounce block already queued on the main runloop may call back later; - // state=null below and the bumped generation each make that callback inert. for (0..pardes.MAX_PANES) |pane| if (st.file_watches.entries[pane] != null) { const id: u8 = @intCast(pane); const generation = st.file_watches.stop(st.gpa, id); @@ -829,13 +774,8 @@ export fn pardes_deinit() void { const generation = st.file_watches.stop(st.gpa, theme_watch_pane); hostWatchFile(theme_watch_pane, generation, null, 0); } - // Every reader is joined here, before anything it touches is freed. The - // runtime joins its tasks on exit, so a reader left parked in read(2) would - // hang the process instead of the app quitting. for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane)); - // Only now is the inbox quiet. Anything still queued owns gpa bytes and - // would show up as a leak rather than as the shutdown it actually is. - st.inbox.close(st.gpa); + st.inbox.close(st.gpa, st.io); st.file_watches.deinit(st.gpa); if (st.cells.len > 0) st.gpa.free(st.cells); if (st.previous_cells.len > 0) st.gpa.free(st.previous_cells); @@ -849,7 +789,7 @@ export fn pardes_deinit() void { st.prompt_rcs.deinit(); st.threaded.deinit(); st.gpa.destroy(st.threaded); - pardes.allocators.deinit(); + pardes.memory.deinit(); state = null; } @@ -858,7 +798,7 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: panel_animation.SceneEffect) u32 { +fn encodeSceneEffects(effects: layout.SceneEffect) u32 { var flags: u32 = 0; if (effects.crt) flags |= scene_flag_crt; if (effects.ripple) flags |= scene_flag_ripple; @@ -870,56 +810,39 @@ fn currentSceneFlags(st: *const State) u32 { return encodeSceneEffects(st.core.settings.scene_effects); } -/// Advance the scene clock by real elapsed time, wrapping so an f32 -/// `time_seconds` keeps sub-millisecond resolution forever. fn advanceSceneClock(st: *State, elapsed_ns: u64) void { st.scene_ns = (st.scene_ns +| elapsed_ns) % scene_wrap_ns; } -/// How much real time this tick may spend, and how many whole fixed steps that -/// buys. Pure arithmetic, split out of `pardes_animation_tick` so the clock the -/// whole feel of the app rides on can be asserted without a display attached. -/// -/// `previous` of zero means "no sample yet" — the first tick of a run, or a -/// monotonic clock that refused to answer — and spends exactly one step rather -/// than the entire uptime. const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) - pardes.animation.frame_ns + pardes.layout.Animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; - while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns; + while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { - const frame = pardes.animation.frame_ns; + const frame = pardes.layout.Animation.frame_ns; const expectEqual = std.testing.expectEqual; - // First tick of a run has nothing to measure from and spends exactly one - // step — never the whole uptime. const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); try expectEqual(@as(u32, 1), first.steps); try expectEqual(frame, first.elapsed_ns); - // A callback that lands ON time buys one step and banks nothing. const on_time = spendTickTime(1_000, 1_000 + frame, 0); try expectEqual(@as(u32, 1), on_time.steps); try expectEqual(@as(u64, 0), on_time.bank_ns); - // THE BUG THIS FIXES. A callback that lands late used to still count as one - // frame, so an animation stretched and ran slow. Two frames' worth of real - // time now buys two steps. const late = spendTickTime(1_000, 1_000 + 2 * frame, 0); try expectEqual(@as(u32, 2), late.steps); - // ...and time too short for a step is BANKED, not discarded: three 6 ms - // callbacks are worth one 16 ms frame, not zero and not three. var bank: u64 = 0; var steps: u32 = 0; for (0..3) |_| { @@ -930,44 +853,19 @@ test "the animation clock spends real time, not callbacks" { try expectEqual(@as(u32, 1), steps); try expectEqual(@as(u64, 2 * std.time.ns_per_ms), bank); - // A stall — occluded window, sleep, breakpoint — is CLAMPED. Resuming an - // animation must not fast-forward it by however long nobody was looking. const stall = spendTickTime(1_000, 1_000 + 10 * std.time.ns_per_s, 0); try expectEqual(max_tick_catch_up_ns, stall.elapsed_ns); try expectEqual(@as(u32, @intCast(max_tick_catch_up_ns / frame)), stall.steps); - // A monotonic clock that refuses to answer, or that goes backwards, spends - // one step rather than a garbage dt. try expectEqual(@as(u32, 1), spendTickTime(5_000, 4_000, 0).steps); } -/// Something on screen moves on its own and wants ~60 Hz ticks: a finite core -/// transition, a persistent scene shader, or the rotation dial coasting after -/// a flick. All are spent only by pardes_animation_tick, so input and pty pumps -/// cannot make frame-count animation run faster than the display clock. export fn pardes_animating() bool { const st = &(state orelse return false); return st.core.animationActive() or st.rotate_coasting; } -/// The colour the host should paint everything the grid does not: the window -/// background behind the titlebar, and behind every pixel of a live resize the -/// view has not caught up with yet. -/// -/// The theme's OWN background, not the chrome's, and so not animated — the -/// same split every other shell draws. Chrome (taglines, the move box, the -/// scrollbar) fades between themes over a handful of frames; document -/// backgrounds switch the instant the theme does, and this is one of those. -/// -/// PARDES_COLOR_DEFAULT means the active theme declares NO background of its -/// own (`bg = null`: the curated `dark`, and every vendored `*_transparent`). -/// In a terminal that means "wear whatever the terminal is wearing"; a window -/// has nothing to wear, so the host lets its own backdrop through — see the -/// NSVisualEffectView in AppDelegate. export fn pardes_theme_bg() u32 { - // Before pardes_init there is no session, but there IS a theme: the ring's - // first entry is what the core boots wearing, so answering with it keeps - // the window from opening one colour and flipping to another a frame later. const th = if (state) |*st| st.core.theme() else &pardes.themes[0]; const bg = th.bg orelse return color_default; return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]; @@ -977,23 +875,10 @@ fn taglineFontPercent(core: ?*const pardes.Pardes) u8 { return if (core) |p| p.settings.font.tagline_percent else pardes.config.gui_tagline_font_percent; } -/// The smaller face used for pane taglines, as a percentage of the body face. -/// Grid geometry always comes from the body face. Before init the compiled -/// default lets the host construct its metrics; afterwards it pulls the live -/// core value so a TaglineSize command is visible on the next host read. export fn pardes_gui_tagline_font_percent() u8 { return taglineFontPercent(if (state) |*st| st.core else null); } -/// Where that smaller band sits inside its body-sized row, and the rule between -/// the topbar band and the first pane-tag band. Both answers come from the core -/// rather than being reimplemented here, because a second copy of this geometry -/// is exactly what left the native shell centring every band while the SDL -/// shell joined them (`pardes.taglineBandOffset`). -/// -/// PHYSICAL PIXELS, like the SDL shell's: a host working in points multiplies -/// by its backing scale on the way in and divides on the way out, which is the -/// same snapping it already does for the cell itself. export fn pardes_tagline_band_offset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { return pardes.taglineBandOffset(row, canvas_h, cell_h, tagline_h); } @@ -1002,34 +887,16 @@ export fn pardes_topbar_pane_border_px(cell_h: u32, tagline_h: u32) u32 { return pardes.topbarPaneBorderPixels(cell_h, tagline_h); } -/// ...and the HORIZONTAL half of the same story: the column a compact tagline -/// band anchors at, so a tag row advances on the tagline face's own pitch -/// instead of dropping a smaller glyph into the middle of every body cell. -/// Without it this shell tracked its tags visibly looser than the SDL window -/// beside it at the same percentage. -/// -/// CELLS, not pixels: the caller already knows both cell widths, and an -/// animating panel's origin is fractional. export fn pardes_tagline_origin_col(col: u16, row: u16) f32 { const st = &(state orelse return @floatFromInt(col)); return pardes.taglineOriginColForFrame(st.core, col, row); } -/// ...and its inverse, for the pointer. A tag row whose glyphs were compacted -/// but whose clicks were not is a click that drifts one word further right for -/// every word along the row, so the layout and the hit test are one feature. -/// -/// `x` and both widths in the SAME unit — this shell measures in POINTS and -/// passes points; only their ratio is read. export fn pardes_grid_col_at(x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const st = &(state orelse return pardes.gridColAt(null, x, row, body_w, tagline_w)); return pardes.gridColAt(st.core, x, row, body_w, tagline_w); } -/// Colour of that rule: the compiled override when a build pins one, otherwise -/// the active theme's scrollbar track — the same resolution the SDL shell does -/// at `src/gui/gui.zig:3813`. PARDES_COLOR_DEFAULT before there is a session to -/// ask, which the host reads as "do not draw the rule yet". export fn pardes_topbar_pane_border_rgb() u32 { const rgb = pardes.config.gui_topbar_pane_border_rgb orelse fromTheme: { const st = state orelse return color_default; @@ -1038,33 +905,12 @@ export fn pardes_topbar_pane_border_rgb() u32 { return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The tag band's own background — `chromeTheme().tag_bg`, the same value the -/// SDL shell builds its `tagline_base` cell from. -/// -/// A host needs it because a compact tag row is painted in two passes: the -/// pane-wide band in THIS colour on the body grid, then each cell's own -/// background on the narrower grid the glyphs use. Without the split, a -/// highlighted word's box lands on body pitch while its letters sit on tagline -/// pitch, and the box drifts further from the word the further along the row -/// it is. PARDES_COLOR_DEFAULT before there is a session to ask. export fn pardes_tagline_bg() u32 { const st = state orelse return color_default; const rgb = st.core.chromeTheme().tag_bg; return @as(u32, rgb[0]) << 16 | @as(u32, rgb[1]) << 8 | rgb[2]; } -/// The shared fallback PREFERENCE ORDER — `fonts.fallback_names`, the same list -/// the SDL shell walks. Only the order is shared; resolving a name is each -/// host's own business, and has to be: SDL matches file stems while walking the -/// font directories itself, and CoreText matches PostScript and family names, -/// which for the same face are routinely different strings. "Mononoki Nerd -/// Font Mono" ships as `MononokiNerdFontMono-Regular.ttf` and answers to -/// `MononokiNFM-Regular`, and a by-stem lookup on this platform silently -/// resolves to Helvetica rather than failing. -/// -/// Returned as pointer + length rather than NUL-terminated because these are -/// Zig string literals and a sentinel copy of each would exist only to be -/// dropped again by the caller. export fn pardes_fallback_font_count() u32 { return fonts.fallback_names.len; } @@ -1093,45 +939,27 @@ test "the fallback preference order crosses the ABI intact and ends at the bound try std.testing.expectEqual(@as(u32, fonts.fallback_names.len), pardes_fallback_font_count()); try std.testing.expect(pardes_fallback_font_count() > 0); - // Every name arrives byte for byte and in the SAME ORDER, which is the - // whole of what is shared: the AppKit shell seeds its CoreText cascade from - // this list and the SDL shell walks the font directories for it, and a - // reordering here would silently give one window a different fallback than - // the other at the same codepoint. for (fonts.fallback_names, 0..) |want, i| { var len: u32 = 0; const got = pardes_fallback_font_name(@intCast(i), &len) orelse return error.MissingFallbackName; try std.testing.expectEqualStrings(want, got[0..len]); } - // Past the end is null AND a zero length: a host that ignores the count and - // walks until null must not read a stale length and copy from a null - // pointer. var len: u32 = 12345; try std.testing.expect(pardes_fallback_font_name(pardes_fallback_font_count(), &len) == null); try std.testing.expectEqual(@as(u32, 0), len); } -/// One coherent snapshot for the host's single scene postprocess. The clock is -/// REAL ELAPSED TIME, advanced only on the scheduled display callback and never -/// on an input or pty drain — so a burst of typing cannot fast-forward a scene -/// effect, and a slow callback no longer slows one down either. export fn pardes_scene() Scene { const st = &(state orelse return .{}); const seconds = @as(f64, @floatFromInt(st.scene_ns)) / @as(f64, std.time.ns_per_s); return .{ .flags = currentSceneFlags(st), .time_seconds = @floatCast(seconds), - // The shader's frame counter is that time expressed in nominal display - // frames; it is a UNIT of the clock now, not the clock itself. .frame = @intFromFloat(seconds * @as(f64, @floatFromInt(scene_frame_hz))), }; } -/// The host could not construct or repeatedly submit the shared Metal/Core -/// Image pass. Stop claiming effects are enabled when only the canonical grid -/// can be presented, stop its otherwise-unbounded display-clock wakeups, and -/// snap any current panels before the direct canonical fallback is drawn. export fn pardes_postprocessor_unavailable() void { const st = &(state orelse return); st.core.disableSceneEffects(); @@ -1140,27 +968,11 @@ export fn pardes_postprocessor_unavailable() void { st.scene_ns = 0; } -/// One transient postprocess submission failed and the host will draw the -/// canonical grid for this frame. A later retry may keep scene effects, but it -/// must not resume a half-finished panel transition after that canonical frame. export fn pardes_panel_animation_failed() void { const st = &(state orelse return); st.core.abandonPanelAnimations(); } -/// The core's per-frame poll: re-read the cwd of every shell that just spoke, -/// and only those. -/// -/// A pane's tag shows this and a relative `Look` resolves against it, so it has -/// to follow the shell around rather than stay at the directory the pane was -/// spawned in. The tty and SDL hosts poll all of them every frame; here the -/// drain has just said exactly which shells produced bytes, and nothing else -/// can have changed one — a `cd` is a command, and a shell that ran a command -/// writes at least its next prompt. So an idle session costs nothing at all, -/// and a busy one costs one libproc call per pane per burst. -/// -/// Whether a shell's tty is still that shell is NOT refreshed here: nothing -/// draws it, so the core pulls it instead (see `ttyTaken`). fn refreshCwds(ctx: ?*anyopaque) void { const st = hostState(ctx); for (&st.cwd_stale, 0..) |*stale, id| { @@ -1168,7 +980,7 @@ fn refreshCwds(ctx: ?*anyopaque) void { stale.* = false; const pt = st.ptys[id] orelse continue; var buf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); + if (host_io.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); } } @@ -1188,9 +1000,8 @@ fn watchInitialGeneration(st: *State, pane: u8, path: []const u8) ?file_watch.Ge return null; } -/// Start watching the path the core resolved for this pane. Turning a watch off -/// is the caller's business (`watchFile`); everything here is the start. -fn setFileWatch(st: *State, pane: u8, path: []const u8) void { +fn setFileWatch(st: *State, pane: u8, path: []const u8, mode: pardes.WatchMode) void { + const native = filesystem.localPath(path) orelse return; const value = st.core.panes[pane] orelse return; const generation_on_disk = watchInitialGeneration(st, pane, path) orelse return; const generation = st.file_watches.replace( @@ -1204,12 +1015,13 @@ fn setFileWatch(st: *State, pane: u8, path: []const u8) void { hostWatchFile(pane, stopped, null, 0); return; }; - const watched = st.file_watches.entries[pane].?; - hostWatchFile(pane, generation, watched.path.ptr, watched.path.len); - // The document was opened before this source existed. Reconcile once only - // AFTER source.activate() so a replacement in that gap is either observed - // here or produces a later directory edge; there is no open-before-watch - // window in which both mechanisms can miss it. + hostWatchFile(pane, generation, native.ptr, native.len); + if (mode == .baseline_disk and value.file != null) { + const bytes = filesystem.read(st.core, path) catch return; + defer st.core.gpa.free(bytes); + st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); + return; + } _ = reloadWatchedFile(st, pane, false); } @@ -1218,7 +1030,7 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo hostWatchFile(theme_watch_pane, stopped, null, 0); if (!on) return; const request = st.core.themeFileRequest(request_generation) orelse return; - const bytes = look.readFile(st.gpa, request.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, request.path) catch |err| { st.core.failThemeFile(request_generation, err); return; }; @@ -1233,8 +1045,6 @@ fn setThemeFileWatch(st: *State, request_generation: u32, on: bool, animate: boo ) catch return; const watched = st.file_watches.entries[theme_watch_pane].?; hostWatchFile(theme_watch_pane, callback_generation, watched.path.ptr, watched.path.len); - // Read-before-watch has the same rename-over gap as document panes. One - // immediate reconciliation after Swift activates the source closes it. _ = reloadWatchedTheme(st, false); } @@ -1242,7 +1052,7 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { const watched = if (st.file_watches.entries[theme_watch_pane]) |*entry| entry else return false; const request = st.core.themeFileRequest(watched.serial) orelse return false; if (!std.mem.eql(u8, watched.path, request.path)) return false; - const bytes = look.readFile(st.gpa, watched.path) catch |err| { + const bytes = filesystem.readFile(st.gpa, watched.path) catch |err| { st.core.failThemeFile(watched.serial, err); return false; }; @@ -1264,11 +1074,6 @@ fn reloadWatchedTheme(st: *State, announce: bool) bool { return true; } -/// Read and apply on the main thread. Swift only says that this path or its -/// parent changed. Text hashes an exact bounded snapshot; PDFs may be much -/// larger than that bound and MuPDF reopens the path itself, so they compare a -/// cheap inode/size/time identity instead. Both transactions enter through the -/// same success-reporting core seam and only then advance their baseline. const WatchReload = enum { no_change, committed, changed_uncommitted }; fn retryWatchedFile(st: *State, pane: u8, generation: u32) void { @@ -1286,11 +1091,11 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { const result: WatchReload = switch (watched.generation_on_disk) { .text => |old_hash| text: { if (current.file == null) break :text .no_change; - const bytes = look.readFile(st.gpa, watched.path) catch { + const bytes = filesystem.read(st.core, watched.path) catch { retryWatchedFile(st, pane, generation); break :text .no_change; }; - defer st.gpa.free(bytes); + defer st.core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); if (hash == old_hash) break :text .no_change; if (!st.core.reloadWatchedFile(pane, bytes)) { @@ -1298,9 +1103,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { break :text .no_change; } - // The call is synchronous, but retain the same lifetime guards as - // the async edge: future refactors cannot bless a reused slot just - // because it happens to carry the same pathname. const after = st.core.panes[pane] orelse break :text .no_change; const active = if (st.file_watches.entries[pane]) |*entry| entry else break :text .no_change; if (active.generation != generation or after.serial != active.serial) @@ -1331,10 +1133,6 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; }; - // The identity must bracket the complete synchronous MuPDF - // transaction. If the path moved during it, leave the old baseline - // in place and spend one bounded retry from the already-armed - // source; correctness does not depend on receiving a second edge. if (!before.eql(after_identity)) { retryWatchedFile(st, pane, generation); break :pdf .changed_uncommitted; @@ -1354,21 +1152,14 @@ fn reloadWatchedFile(st: *State, pane: u8, announce: bool) bool { return result != .no_change; } -/// FileWatcher.swift calls this from DispatchQueue.main after its short quiet -/// period. Do not touch the core here: schedule the ordinary pump so all file -/// IO and state mutation stay in pardes_tick with pty/nested messages. export fn pardes_watch_changed(pane: u8, generation: u32) void { const st = &(state orelse return); if (pane >= watch_slot_count) return; if (st.file_watches.notify(pane, generation)) wake(st); } -/// What arrived off the loop thread since the last tick: pty output, a reaped -/// shell, a nested `Look`, and the file-watch edges Swift debounced. Every one -/// of them carries borrowed bytes, so they go straight into `update` rather -/// than through the core's event queue. fn drainInbox(st: *State) bool { - var batch = st.inbox.take(); + var batch = st.inbox.take(st.io); var did = batch.len > 0; for (batch.slice()) |msg| { defer msg.free(st.gpa); @@ -1380,39 +1171,20 @@ fn drainInbox(st: *State) bool { }, .eof => |e| { if (st.gens[e.pane] != e.gen) continue; - // The shell is gone: join its reader (a completed future that - // is never awaited leaks its allocation), close the master and - // free the slot. reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, - // Already filtered down to `Look ` by the accept side — this - // socket may open things and that is all it may do. - .command => |c| st.core.update(.{ .command = c }), - // The rows the worker produced, back into the request the core is - // still holding open. Joining the future here is what keeps a - // completed task from leaking its allocation. .lsp_done => |d| { st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - if (st.lsp_task) |*t| { - t.cancel(st.io) catch {}; + if (st.lsp_task) |*t| if (t.id == d.id) { + t.future.await(st.io) catch {}; st.lsp_task = null; - } + }; }, - // "rust-analyzer: cargo check 88%" onto the transient message row, - // on the ACTIVE pane: server state is session news, not a fact - // about whichever pane happened to ask. .lsp_status => |text| { var mbuf: [256]u8 = undefined; st.core.setStatus(st.core.active, message.stamp(&mbuf, "lsp", text)); }, - // The filter's answer, then join the worker that produced it. - // - // NO deinit here: this loop's `defer msg.free(st.gpa)` owns the - // response, and `Msg.free` deinits it. The SDL shell frees inside - // its arm because its queue has no blanket free — copying that arm - // across without the surrounding contract is a double free, which - // is exactly what it was until the first `|` crashed the app. .pipe => |value| { st.core.update(.{ .pipe_resp = .{ .id = value.id, @@ -1435,51 +1207,225 @@ fn drainInbox(st: *State) bool { return did; } -/// Hand the core what arrived off-thread, then perform whatever it queued in -/// response. Returns whether this tick had IO to do, which is what bounds the -/// app's "pump until quiet" drain loop. -/// -/// It deliberately does NOT render. AppKit wants to be TOLD the view is dirty -/// and to draw once per display refresh: a pty burst is a dozen wakeups and a -/// dozen ticks, and rendering inside each of them would encode eleven grids -/// nobody ever sees. The render is `pardes_frame`, which the draw callback -/// calls at display cadence — the coalescing this whole boundary is shaped -/// around, and what src/macos/pardes.h has always said pardes_frame is. -/// -/// NOT a repaint signal, however tempting: the core changes the grid on its own -/// for a cursor move, a selection, a mode change and a scroll, none of which -/// queue an effect or read a pty, so all four return false here. The macOS host -/// learned that the expensive way — see the comment on pump() in -/// src/macos/Sources/AppDelegate.swift. export fn pardes_tick() bool { const st = &(state orelse return false); - // Cleared before the drain: a reader that pushes during this tick must be - // able to schedule the next one. st.inbox.wake_pending.store(false, .release); var did = drainInbox(st); - // Straight to `perform`, not through `pump`: the effects are the IO half of - // a tick and the render is not. `core.host` was seated once at init and is - // this host for the life of the session, so both this loop and the - // `tty_taken` pull the next keystroke makes land here. + if (st.ninep) |listener| { + const drained = listener.tick(st.core); + did = did or drained.count != 0; + if (drained.pending) wake(st); + } while (st.core.nextEffect()) |effect| { did = true; st.core.perform(effect); } + if (restoreCore(st)) did = true; return did; } -/// Spend the real time elapsed since the previous tick. Event pumps deliberately -/// never call this: a burst of key, mouse, or pty notifications is work to -/// drain, not elapsed animation time. +fn restoreCore(st: *State) bool { + if (st.core.quit) return false; + const path = st.core.takeRestore() orelse return false; + const bytes = filesystem.readFile(st.gpa, path) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + defer st.gpa.free(bytes); + const replacement = st.core.restore(bytes) catch |err| { + st.core.reportError(st.core.active, "Restore", err); + return false; + }; + if (st.lsp_task) |*task| { + task.future.cancel(st.io) catch {}; + st.lsp_task = null; + } + st.pipe_tasks.cancelAll(st.io); + for (0..pardes.MAX_PANES) |pane| { + reap(st, @intCast(pane)); + st.gens[pane] +%= 1; + } + var stale = st.inbox.take(st.io); + for (stale.slice()) |msg| msg.free(st.gpa); + for (0..watch_slot_count) |pane| if (st.file_watches.entries[pane] != null) { + const id: u8 = @intCast(pane); + const generation = st.file_watches.stop(st.gpa, id); + hostWatchFile(id, generation, null, 0); + }; + if (st.ninep) |listener| listener.reset(st.core); + replacement.host = hostFor(st); + st.core.deinit(); + st.core = replacement; + clearFrame(st); + st.cwd_stale = @splat(false); + st.scroll_lag = 0; + st.scroll_lag_x = 0; + st.rotate_lag = 0; + st.rotate_velocity = 0; + st.rotate_last_ns = 0; + st.rotate_coasting = false; + st.scene_ns = 0; + st.last_tick_ns = 0; + st.tick_bank_ns = 0; + return true; +} + +test "mac Restore keeps host state and rejects callbacks from the old core" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = std.testing.io, + .core = core, + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + .frame_len = 7, + .rotate_coasting = true, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, std.testing.io); + const marker = try st.config_arena.allocator().dupe(u8, "host configuration"); + _ = try core.setTestFile("saved body\n"); + const old_serial = core.panes[0].?.serial; + st.gens[0] = 23; + const old_watch = try st.file_watches.replace(gpa, 0, "/test.txt", old_serial, .{ .text = 0 }); + try core.dumpState(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "restore.zon", .data = core.dump_out.? }); + while (core.nextEffect()) |_| {} + + var command: [512]u8 = undefined; + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/missing.zon", .{tmp.sub_path}) }); + try std.testing.expect(!restoreCore(&st)); + try std.testing.expectEqual(core, st.core); + try std.testing.expectEqual(old_watch, st.file_watches.entries[0].?.generation); + try std.testing.expect(st.rotate_coasting); + + core.lspRequest(0, .status, ""); + const old_request = core.lsp_wait.?.id; + st.lsp_task = .{ .id = old_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = old_request, .rows = try gpa.dupe(u8, "old result") } }); + for (0..selection_pipe.Tasks.capacity) |index| { + const id: u32 = @intCast(index); + try std.testing.expect(st.pipe_tasks.add(.{ .id = id, .future = .{ .any_future = null, .result = {} } })); + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ + .id = id, + .success = false, + .outputs = &.{}, + .failure = .{ .kind = .exit, .code = 1, .stderr = try gpa.dupe(u8, "old filter failure") }, + } }); + } + try st.inbox.pushOutput(gpa, std.testing.io, .{ .pane = 0, .gen = 23, .bytes = try gpa.dupe(u8, "old PTY output") }); + core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.core != core); + try std.testing.expect(st.core.panes[0].?.serial > old_serial); + try std.testing.expectEqualStrings("saved body\n", st.core.panes[0].?.file.?.content); + try std.testing.expectEqualStrings("host configuration", marker); + try std.testing.expectEqual(@as(usize, 0), st.frame_len); + try std.testing.expect(!st.rotate_coasting); + try std.testing.expectEqual(@as(u32, 24), st.gens[0]); + try std.testing.expect(!st.file_watches.notify(0, old_watch)); + try std.testing.expect(st.file_watches.generations[0] > old_watch); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(!drainInbox(&st)); + try std.testing.expect(!st.cwd_stale[0]); + + st.core.lspRequest(0, .status, ""); + const new_request = st.core.lsp_wait.?.id; + try std.testing.expect(new_request > old_request); + st.lsp_task = .{ .id = new_request, .future = .{ .any_future = null, .result = {} } }; + st.inbox.push(gpa, std.testing.io, .{ .lsp_done = .{ .id = new_request, .rows = &.{} } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.lsp_task == null); + try std.testing.expect(st.core.lsp_wait == null); + + const pane = st.core.panes[0].?; + pane.cur_col = 4; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + st.core.update(.{ .key = .{ .cp = '|' } }); + st.core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + st.core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = st.core.pipe_wait.?.id; + try std.testing.expect(st.pipe_tasks.add(.{ .id = pipe_id, .future = .{ .any_future = null, .result = {} } })); + const output = try gpa.dupe(u8, "FRESH"); + const outputs = gpa.dupe([]u8, &.{output}) catch |err| { + gpa.free(output); + return err; + }; + st.inbox.push(gpa, std.testing.io, .{ .pipe = .{ .id = pipe_id, .success = true, .outputs = outputs } }); + try std.testing.expect(drainInbox(&st)); + try std.testing.expect(st.core.pipe_wait == null); + try std.testing.expectEqual(@as(usize, 0), st.pipe_tasks.len); + try std.testing.expectEqualStrings("FRESH body\n", pane.file.?.content); +} + +test "mac Restore cancels a PTY reader waiting for output capacity" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var st: State = .{ + .gpa = gpa, + .threaded = undefined, + .io = io, + .core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }), + .runtime = .{}, + .config_arena = .init(gpa), + .prompt_rcs = .{}, + }; + defer st.core.deinit(); + defer st.config_arena.deinit(); + defer st.file_watches.deinit(gpa); + defer st.inbox.close(gpa, io); + try st.core.dumpState(); + try tmp.dir.writeFile(io, .{ .sub_path = "restore.zon", .data = st.core.dump_out.? }); + while (st.core.nextEffect()) |_| {} + for (0..inbox_output_limit) |_| try st.inbox.pushOutput(gpa, io, .{ + .pane = 0, + .gen = 1, + .bytes = try gpa.dupe(u8, "retained output"), + }); + const child = try host_io.forkShell(null, 0, &st.prompt_rcs, "/bin/sh", "", 12, 40, null); + st.gens[0] = 1; + st.ptys[0] = .{ .file = child.file, .pid = child.pid, .gen = 1, .reader = .{ .any_future = null, .result = {} } }; + defer { + if (st.ptys[0]) |pt| if (pt.pid == child.pid) reap(&st, 0); + if (libc.waitpid(child.pid, null, posix.W.NOHANG) == 0) { + _ = libc.kill(child.pid, libc.SIG.KILL); + _ = libc.waitpid(child.pid, null, 0); + } + } + startReader(&st, &st.ptys[0].?, 0); + try std.testing.expect(host_io.writeFd(child.file.handle, "printf 'after-full'; exit\n")); + for (0..1000) |_| { + if (st.inbox.space.state.load(.acquire).waiters != 0) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expectEqual(@as(u16, 1), st.inbox.space.state.load(.acquire).waiters); + var command: [512]u8 = undefined; + st.core.update(.{ .command = try std.fmt.bufPrint(&command, "Restore .zig-cache/tmp/{s}/restore.zon", .{tmp.sub_path}) }); + try std.testing.expect(restoreCore(&st)); + try std.testing.expect(st.ptys[0] == null); + try std.testing.expectEqual(@as(usize, 0), st.inbox.len); + try std.testing.expectEqual(@as(u16, 0), st.inbox.space.state.load(.acquire).waiters); + try st.inbox.pushOutput(gpa, io, .{ .pane = 0, .gen = st.gens[0], .bytes = try gpa.dupe(u8, "fresh output") }); + var batch = st.inbox.take(io); + defer for (batch.slice()) |msg| msg.free(gpa); + try std.testing.expectEqual(@as(usize, 1), batch.len); + try std.testing.expectEqualStrings("fresh output", batch.items[0].output.bytes); +} + export fn pardes_animation_tick() bool { const st = &(state orelse return false); - // MEASURED elapsed time, not one assumed frame. The scheduler re-arms only - // after the previous frame's tick, drain and draw have finished, so on the - // fallback clock the callbacks land slower than 60 Hz and unevenly. - // Counting each as one frame made every animation run slow AND stutter; - // spending real time makes cadence a question of smoothness only, and no - // longer a question of speed. const now: u64 = @intCast(@max(0, monotonicNs())); const spend = spendTickTime(st.last_tick_ns, now, st.tick_bank_ns); st.last_tick_ns = now; @@ -1487,15 +1433,10 @@ export fn pardes_animation_tick() bool { var changed = false; if (currentSceneFlags(st) != 0) { - // Shader time is wall-clock seconds, so a scene effect runs at the same - // rate whatever the callback cadence turns out to be. advanceSceneClock(st, spend.elapsed_ns); changed = true; } - // The core's transitions and the dial's coast are FIXED-STEP: they count - // frames. The banked time is spent in whole steps, so a late callback - // advances two frames rather than stretching one over 32 ms. for (0..spend.steps) |_| { if (st.core.animationActive()) { st.core.update(.tick); @@ -1507,17 +1448,11 @@ export fn pardes_animation_tick() bool { if (@abs(st.rotate_velocity) < rotation_fling_stop) { st.rotate_velocity = 0; st.rotate_coasting = false; - // The remainder dies with the gesture: a banked half-notch - // surviving into the next twist is the hysteresis `rotate 0` - // exists to clear. st.rotate_lag = 0; } changed = true; } } - // Nothing is animating any more: drop the banked remainder so the next run - // starts on a whole step instead of jumping however far this one stopped - // short, and forget the stamp so its first dt is not the idle gap. if (!changed) { st.tick_bank_ns = 0; st.last_tick_ns = 0; @@ -1525,8 +1460,6 @@ export fn pardes_animation_tick() bool { return changed; } -// ---------------------------------------------------------------- events in - export fn pardes_key(cp_arg: u32, text_ptr: ?[*]const u8, len: usize, mods: u32) void { const st = &(state orelse return); if (cp_arg > std.math.maxInt(u21)) return; @@ -1546,9 +1479,6 @@ export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void { st.core.update(.{ .paste = text }); } -/// Button and kind arrive as their boundary ordinals. An out-of-range value is -/// dropped rather than reaching an unchecked enum cast — same rule the browser -/// ABI keeps, for the same reason: the host is not part of this build. export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void { const st = &(state orelse return); const button: pardes.Mouse.Button = switch (button_arg) { @@ -1596,10 +1526,6 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo .row = row, } }); } - // Horizontal after vertical, and through the same quantizer: the core's - // own drift guard (config.wheelTick) is what decides whether a sideways - // wobble during a vertical flick counts, so the shell must not second-guess - // it by filtering here. var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); while (right_left != 0) { const right = right_left > 0; @@ -1613,16 +1539,8 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo } } -/// Spend a trackpad rotation as search steps. AppKit reports degrees since the -/// last event, counterclockwise positive; the core has no rotation, so the -/// dial is quantized into the keys a hand would otherwise press — clockwise is -/// `n` (forward through the matches), counterclockwise `N`. export fn pardes_rotate(degrees: f32) void { const st = &(state orelse return); - // A gesture beginning re-zeros the dial: leftover travel from the last - // twist must not make the first degree of this one jump a match — and it - // catches a fling still coasting, because a finger back down is how a hand - // catches a dial. if (degrees == 0) { st.rotate_lag = 0; st.rotate_velocity = 0; @@ -1634,19 +1552,11 @@ export fn pardes_rotate(degrees: f32) void { spendRotation(st, degrees); } -/// The fingers lifted. What happens next is decided entirely by how fast they -/// were moving when they did: `rotationFling` subtracts the floor, so a slow -/// twist stops dead where it was put and a flick keeps going in proportion to -/// how hard it was thrown. export fn pardes_rotate_end() void { const st = &(state orelse return); const last = st.rotate_last_ns; st.rotate_last_ns = 0; st.rotate_coasting = false; - // A hand that turned the dial, STOPPED, and then lifted has released at - // rest however fast it was moving before — and the last sample is still - // sitting there saying otherwise. Without this the most deliberate twist - // of all (turn, look at it, let go) is the one that flings. if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) { st.rotate_velocity = 0; return; @@ -1655,23 +1565,12 @@ export fn pardes_rotate_end() void { st.rotate_coasting = st.rotate_velocity != 0; } -/// Monotonic nanoseconds, the clock lsp_zls.zig already times with. Monotonic -/// and not REALTIME on purpose: a dial that flung because NTP stepped the wall -/// clock backwards would be a bug nobody ever reproduces. -/// -/// Zero on failure, which is also the "no sample yet" sentinel — so a clock -/// that will not answer makes the dial refuse to fling rather than fling on a -/// garbage dt. fn monotonicNs() i128 { var ts: libc.timespec = undefined; if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; } -/// One event's contribution to the velocity estimate, in degrees per second. -/// Smoothed, because a single 120 Hz sample of a human wrist is mostly noise -/// and the fling would otherwise be decided by whichever one happened to land -/// last. fn noteRotationVelocity(st: *State, degrees: f32) void { const now = monotonicNs(); const last = st.rotate_last_ns; @@ -1679,8 +1578,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_coasting = false; if (last == 0 or now == 0) return; const dt_ns = now - last; - // A gap this long is a gesture nobody announced the start of, not a slow - // one: dividing by it would report a crawl and eat a real fling. if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return; const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s)); const sample = degrees / seconds; @@ -1688,9 +1585,6 @@ fn noteRotationVelocity(st: *State, degrees: f32) void { st.rotate_velocity = st.rotate_velocity * 0.35 + sample * 0.65; } -/// Turn degrees into whole search steps, keeping the remainder. The one place -/// the dial reaches the core, so a hand-turned notch and a coasted one are the -/// same keystroke by construction. fn spendRotation(st: *State, degrees: f32) void { var left = takeRotationNotches(&st.rotate_lag, degrees); while (left != 0) { @@ -1721,36 +1615,27 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) } }); } -// ---------------------------------------------------------------- frame out - -/// Render one frame, and the only place this host renders: AppKit's draw -/// callback, which is the one call it coalesces. A burst of input or pty output -/// marks the view dirty many times and is drawn once, so however much work the -/// ticks above drained, the grid is encoded once per display refresh. -/// -/// It is the core's whole loop iteration — drain, perform, poll, render, -/// present — and it cannot block: `wait_input` is null, because AppKit -/// delivered the events before it called us and sleeping inside a run-loop -/// callback is a beachball. `present` copies the result into the flat buffers -/// the accessors below describe (presentFrame); returns their cell count, or 0 -/// if the render failed. export fn pardes_frame() u32 { const st = &(state orelse return 0); - // The macOS host had NO zones at all, so every capture attributed its whole - // frame to the core. This is the boundary the AppKit `draw(_:)` calls into. const tz = tracy.zone(@src(), "pardes_frame"); defer tz.end(); + _ = restoreCore(st); st.core.pump(hostFor(st)) catch |err| { log.err("render failed: {t}", .{err}); clearFrame(st); return 0; }; + if (restoreCore(st)) { + st.core.pump(hostFor(st)) catch |err| { + log.err("render failed: {t}", .{err}); + clearFrame(st); + return 0; + }; + } tracy.frameMark(); return @intCast(st.frame_len); } -/// Everything the accessors below describe is emptied together, so a failure -/// can never leave last frame's buffer behind a fresh cols/rows. fn clearFrame(st: *State) void { st.frame_len = 0; st.frame_cols = 0; @@ -1760,9 +1645,6 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -/// Copy one rendered frame into the flat buffers the native renderer reads. -/// Core-owned Cell layout is never borrowed across the ABI, so the grid, the -/// panel diff, the attachments and the tracks are all encoded here. fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { const st = hostState(ctx); const tz = tracy.zone(@src(), "presentFrame"); @@ -1785,9 +1667,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { st.frame_cols = surface.cols; st.frame_rows = surface.rows; { - // One encode per cell, every frame, whether or not the cell changed. - // If this is the hot zone the answer is a dirty-range copy, not a - // faster encodeCell. const tz_cells = tracy.zone(@src(), "encodeCells"); defer tz_cells.end(); for (surface.cells, st.cells[0..count]) |cell, *out| out.* = encodeCell(cell); @@ -1797,15 +1676,6 @@ fn presentFrame(ctx: ?*anyopaque, surface: *const pardes.Surface) void { collectPanelTracks(st, surface); } -/// Flatten tracks into the C-visible array the shader composites from. -/// -/// A plain copy, and that is the point. This used to re-sort by phase into -/// moving/opening/closing — which is EXACTLY the order `Pardes.render` already -/// publishes them in ("Moving panes first, then new panes, then inert closing -/// tombstones on top", src/pardes.zig), and it re-filtered `active()` the core -/// had already filtered. A second ordering rule that happens to agree is not -/// free: it is the thing that silently stops agreeing. The core's order is the -/// contract; every host receives the same dense record set. fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { const source = surface.panelTracks(); const len = @min(source.len, st.panel_tracks.len); @@ -1813,9 +1683,6 @@ fn collectPanelTracks(st: *State, surface: *const pardes.Surface) void { st.panel_tracks_len = len; } -/// Copy the old/new semantic transition data as one all-or-nothing snapshot. -/// A missing allocation disables the optional diff for this frame; it never -/// leaves a previous grid paired with a mask from another render. fn collectPanelDiff(st: *State, surface: *const pardes.Surface, count: usize) void { if (!surface.hasPanelDiff() or count == 0) return; if (st.previous_cells.len != count) { @@ -1843,11 +1710,6 @@ fn encodeChanged(diff: pardes.PanelCellDiff) u8 { return if (diff.changed()) 255 else 0; } -/// Flatten Surface.images into the flat C array the host walks. -/// -/// A dropped attachment is a page that does not draw, never a wrong one, so -/// every failure here just stops collecting: the frame is still valid, it -/// simply has fewer pictures in it than the core offered. fn collectImages(st: *State, surface: *const pardes.Surface) void { if (comptime !pardes.pdf_enabled) return; if (surface.nimages == 0) return; @@ -1861,10 +1723,6 @@ fn collectImages(st: *State, surface: *const pardes.Surface) void { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (place.iw == 0 or place.ih == 0 or place.rgba.len == 0) continue; - // Continuous documents hand over geometry the core already clipped to - // the viewport. Anything else (a static image pane) is the whole - // raster scaled into the whole body, which is the same two rectangles - // spelled without a crop. const geometry = place.native.geometry orelse image.NativeGeometry{ .src = .{ .x = 0, .y = 0, .w = @intCast(place.iw), .h = @intCast(place.ih) }, .dst = .{ @@ -1921,10 +1779,6 @@ export fn pardes_frame_panel_track_list() ?[*]const PanelTrack { return if (st.panel_tracks_len == 0) null else st.panel_tracks[0..].ptr; } -/// AppKit calls this only after its destination context has accepted the -/// frame. The boolean keeps the ABI POD-only: animated presentation uses the -/// borrowed records from `pardes_frame`, while a direct fallback commits the -/// canonical grid with an empty snapshot. export fn pardes_frame_presented(animated_panels: bool) bool { const st = &(state orelse return false); const was_animating = st.core.animationActive(); @@ -1981,9 +1835,6 @@ export fn pardes_cursor_bar() bool { return if (st.core.surface.cursor) |c| c.bar else false; } -/// The acme verb the core last performed, and clears it. Ordinals, not the -/// enum: the host is not part of this build, so the boundary speaks integers -/// and the ABI guard asserts they are the ones the header names. export fn pardes_take_haptic() c_int { const st = &(state orelse return 0); return switch (st.core.takeHaptic()) { @@ -1993,13 +1844,6 @@ export fn pardes_take_haptic() c_int { }; } -/// The file the `Font` builtin asked for, and clears it — the same take-once -/// shape as the haptic above, and the same one the SDL shell uses on this -/// exact variable. -/// -/// A copy rather than the borrowed State slice: C wants a terminator. One -/// static buffer because there is one core and the header promises the value -/// only until the next call. var font_path_z: [4096:0]u8 = undefined; export fn pardes_font_take() ?[*:0]const u8 { @@ -2012,8 +1856,6 @@ export fn pardes_font_take() ?[*:0]const u8 { return &font_path_z; } -/// Observe the face already on screen without resolving an unrelated Font -/// request. Initial state, host-only zoom and display-scale changes use this. export fn pardes_font_observe( effective_name: ?[*]const u8, len: usize, @@ -2025,7 +1867,6 @@ export fn pardes_font_observe( return st.core.observeFont(ptr[0..len], point_hundredths, .points); } -/// Commit what CoreText accepted for the request returned by font_take. export fn pardes_font_ack( effective_name: ?[*]const u8, len: usize, @@ -2037,21 +1878,11 @@ export fn pardes_font_ack( return st.core.acknowledgeFont(ptr[0..len], point_hundredths, .points); } -/// Resolve a taken request which CoreText could not load without claiming the -/// fallback/previous face was the requested one. export fn pardes_font_reject() void { const st = &(state orelse return); st.core.rejectFont(); } -/// The FILE behind the focused pane, or null when there is none — a terminal, -/// an output buffer (`+Search` names a directory, not a document), or nothing -/// focused at all. A PDF and an image both count: they are real paths on disk, -/// and the titlebar's proxy icon is about the file, not about who can edit it. -/// -/// A copy into a static buffer for the reason pardes_font_take keeps one: the -/// core owns a length and no terminator, C wants a string, and there is one -/// core. Valid until the next call. var active_path_z: [4096:0]u8 = undefined; export fn pardes_active_path() ?[*:0]const u8 { @@ -2063,10 +1894,6 @@ export fn pardes_active_path() ?[*:0]const u8 { return &active_path_z; } -/// Does the focused pane hold edits that are not on disk? False for everything -/// that cannot be saved in the first place, which is the same set -/// pardes_active_path answers null for minus the PDFs and images — those have -/// a path but no buffer, so they are never dirty. export fn pardes_active_dirty() bool { const st = &(state orelse return false); const pane = st.core.panes[st.core.active] orelse return false; @@ -2083,78 +1910,48 @@ fn activeFilePath(st: *State) ?[]const u8 { return null; } -// ---------------------------------------------------------------- host seam - -/// What this host can do, for the core's own loop to call. What it deliberately -/// cannot: -/// * `wait_input` — AppKit delivered the events before it called us and owns -/// the sleep; blocking inside a run-loop callback is a beachball. -/// * `post_present` — presentation is acknowledged when the destination -/// context has accepted the frame (pardes_frame_presented), which is a -/// later callback, not the moment the cells were encoded. -/// * `pipe` — no worker to hand a job to yet, so a `|` filter does nothing -/// in this shell. Teardown is not a method at all: pardes_deinit is the -/// app's own call, made after AppKit's loop rather than from inside one. -/// -/// `lsp` USED to be on that list, and the entry claimed the core's empty answer -/// was "exactly what this host replied". It was not a considered trade: it -/// meant every language query in the shipped Mac app did nothing, silently, and -/// looked from the outside like a backend with no answer rather than a host -/// with no method. It is now `lspRequest` over the shared `lsp_host` worker. -/// -/// Watch is deliberately different again: FileWatcher.swift owns its -/// per-directory DispatchSource and only returns a debounced hint; these -/// main-thread methods own the bytes, hash and shared text/PDF core event. const vtable: pardes.Host.VTable = .{ - .push_present = presentFrame, - .push_poll_frame = refreshCwds, - .push_spawn = spawnShell, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lspRequest, - .pull_pipe = pipeRequest, + .present = presentFrame, + .poll_frame = refreshCwds, + .spawn = spawnShell, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lspRequest, + .pipe = pipeRequest, }; fn hostFor(st: *State) pardes.Host { return .{ .ctx = st, .vtable = &vtable }; } -/// Answer a language query off the main thread and post the rows back. The -/// snapshot and the worker body are `lsp_host`'s, shared with the tty and SDL -/// shells; what is left here is the only part that is actually this host's — -/// which allocator, and how a finished job reaches the main thread. -fn lspRequest(ctx: ?*anyopaque, req: host_api.LspRequest) void { +fn lspRequest(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const st = hostState(ctx); - const job = lsp_host.snapshot(st.gpa, st.core, req) orelse return; - // One in flight. Replacing it cancels the previous worker, which is right: - // the only answer anyone is waiting for is the one just asked for. + const job = host_io.Lsp.snapshot(st.gpa, st.core, req) catch |err| { + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); + }; if (st.lsp_task) |*old| { - old.cancel(st.io) catch {}; + old.future.cancel(st.io) catch {}; st.lsp_task = null; } - st.lsp_task = st.io.concurrent(lspWorker, .{ st, job }) catch { + const future = st.io.concurrent(lspWorker, .{ st, job }) catch |err| { job.free(st.gpa); - return; + st.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return st.core.reportError(req.pane, "lsp", err); }; + st.lsp_task = .{ .id = req.id, .future = future }; } -/// Run a `|` filter off the main thread. The job copy, the subprocess and the -/// response all belong to `selection_pipe`; what is here is this host's inbox -/// and its bounded in-flight table. -/// -/// This shell had no `pull_pipe` at all, so `pardes.zig` self-answered every -/// filter as failed — a `|` in the Mac app silently did nothing, the same shape -/// of gap `pull_lsp` was. fn pipeRequest(ctx: ?*anyopaque, id: u32) void { const st = hostState(ctx); if (st.pipe_tasks.full()) { @@ -2162,10 +1959,14 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { return; } const view = st.core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(st.gpa, view) catch return; - const future = st.io.concurrent(pipeWorker, .{ st, job }) catch { + const job = selection_pipe.Job.copy(st.gpa, view) catch |err| { + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); + }; + const future = st.io.concurrent(pipeWorker, .{ st, job }) catch |err| { job.deinit(st.gpa); - return; + st.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return st.core.reportError(st.core.active, "pipe", err); }; std.debug.assert(st.pipe_tasks.add(.{ .id = id, .future = future })); } @@ -2173,28 +1974,24 @@ fn pipeRequest(ctx: ?*anyopaque, id: u32) void { fn pipeWorker(st: *State, job: *selection_pipe.Job) anyerror!void { defer job.deinit(st.gpa); const response = selection_pipe.runJob(st.gpa, st.io, job); - st.inbox.push(st.gpa, .{ .pipe = response }); + st.inbox.push(st.gpa, st.io, .{ .pipe = response }); wake(st); } -fn lspWorker(st: *State, job: *lsp_host.Job) anyerror!void { - lsp_host.work(st.gpa, job, st, deliverLspRows); +fn lspWorker(st: *State, job: *host_io.Lsp.Job) anyerror!void { + host_io.Lsp.work(st.gpa, job, st, deliverLspRows); } -fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: []u8) void { +fn deliverLspRows(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); - st.inbox.push(st.gpa, .{ .lsp_done = .{ .id = id, .rows = rows } }); + st.inbox.push(st.gpa, st.io, .{ .lsp_done = .{ .id = id, .rows = rows } }); wake(st); } -/// The registered `lsp.setStatusSink` target, called from the protocol client's -/// READER threads. Thread-safe and non-blocking only: a dupe and an inbox push, -/// which is lossy for this message kind by design — the sink's lock is held -/// around this call and server state is periodic news. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const st: *State = @ptrCast(@alignCast(ctx orelse return)); const copy = st.gpa.dupe(u8, text) catch return; - st.inbox.push(st.gpa, .{ .lsp_status = copy }); + st.inbox.push(st.gpa, st.io, .{ .lsp_status = copy }); wake(st); } @@ -2205,35 +2002,19 @@ fn hostState(ctx: ?*anyopaque) *State { fn spawnShell(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const st = hostState(ctx); const core = st.core; - // The core reuses pane ids and has no close effect, so a deleted pane's - // shell lives in its slot until a respawn lands here. Reap it: cancel joins - // the reader, and the generation bump makes its late bytes and eof - // unreadable. reap(st, pane); st.gens[pane] +%= 1; const gen = st.gens[pane]; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - // <= because writing the sentinel slot of a [N:0]u8 is legal, and Effect's - // cwd buffer is exactly 256: `<` would silently drop a maximal path and - // start the shell wherever the app bundle was launched from instead. - if (cwd.len > 0 and cwd.len <= cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w, null); + const child = host_io.forkShell(core, pane, &st.prompt_rcs, core.shellBin(), cwd, core.screen_h, core.screen_w, st.ninep) catch |err| return core.reportError(pane, "shell", err); st.ptys[pane] = .{ .file = child.file, .pid = child.pid, .gen = gen, .reader = .{ .any_future = null, .result = {} }, }; - // Report the pane's starting directory back to the core (tags); the slot - // needs no occupancy reset, nothing is remembered. var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); + if (host_io.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(pane, wd); if (st.started) if (st.ptys[pane]) |*pt| startReader(st, pt, pane); } @@ -2249,37 +2030,22 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } -/// `pty/ctl`'s `sig`. Unlike `ttyTaken` above this is NOT degraded on darwin: -/// `tcgetpgrp` on the master and `kill` are both POSIX, and neither needs the -/// libproc descendant walk `look.ttyTaken` is still waiting for. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const st = hostState(ctx); - if (st.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + if (st.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } -/// Asked only where a command line is about to be typed: is a program holding -/// this pane's tty instead of the prompt we forked? `look.ttyTaken` answers -/// `false` on darwin until it grows a libproc implementation, so this host -/// behaves exactly as it did — the wiring is here so it cannot rot, and it -/// costs nothing until then. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const st = hostState(ctx); const pt = st.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); + return host_io.ttyTaken(pt.pid, pt.file.handle); } -/// A file pane's save and a scrollback's both land here; the core has already -/// resolved which path and which bytes. fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const st = hostState(ctx); - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(st.core, path, bytes) catch |err| return st.core.saveFailed(pane, "save", err); - // The directory source will observe our own close. Move its baseline first - // so that notification is a hash no-op instead of manufacturing an external - // reload and undo boundary. st.file_watches.restampText(pane, path, std.hash.Wyhash.hash(0, bytes)); - // After the write, not beside it: every early return above is a save that - // did not happen and must not be reported as one. var mbuf: [256]u8 = undefined; st.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -2288,20 +2054,18 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const st = hostState(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return st.core.reportError(0, "dump", err); + filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err); st.core.setLastDump(path); } -fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool) void { +fn watchFile(ctx: ?*anyopaque, pane: u8, path: []const u8, on: bool, mode: pardes.WatchMode) void { const st = hostState(ctx); - // No path is a pane with nothing on disk to watch (an output buffer, an - // image), which is the same answer as being turned off. if (!on or path.len == 0) { const generation = st.file_watches.stop(st.gpa, pane); hostWatchFile(pane, generation, null, 0); return; } - setFileWatch(st, pane, path); + setFileWatch(st, pane, path, mode); } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { @@ -2312,7 +2076,7 @@ fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const st = hostState(ctx); const config_dir = st.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(st.io, st.gpa, config_dir, pardes.themes) catch |err| { st.core.reportError(pane, "dump themes", err); return; }; @@ -2327,11 +2091,6 @@ fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { cb(st.runtime.userdata, text.ptr, text.len); } -/// The host answers with pardes_paste, which the AppDelegate calls straight -/// back inside this call: NSPasteboard reads are synchronous, so the paste -/// event lands mid-pump. That is safe and deliberate — pardes_paste only feeds -/// core.update, and whatever that queues is picked up by the same effect loop -/// rather than waiting a tick. A host with a null callback simply never pastes. fn readClipboard(ctx: ?*anyopaque) void { const st = hostState(ctx); const cb = st.runtime.read_clipboard orelse return; @@ -2342,39 +2101,21 @@ fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); } -// ---------------------------------------------------------------- workers - fn startReader(st: *State, pt: *Pty, id: u8) void { pt.reader = st.io.concurrent(readPty, .{ st, st.io, pt.file, id, pt.gen }) catch |err| { - // No reader means the shell fills its pty buffer, blocks in write(2) - // and the pane silently freezes. Nothing recovers it, so at least say - // so — this is what PARDES_LOG exists for. log.err("pane {d} has no reader ({t}); it will not show output", .{ id, err }); return; }; } -/// Release one pane's shell: join the reader, close the master, reap the child. -/// Order matters — cancel is what unblocks a task parked in read(2), and the fd -/// must not be closed under a live reader. Called on eof and again on a spawn -/// into the same slot, so it has to tolerate an empty slot. fn reap(st: *State, pane: u8) void { var pt = st.ptys[pane] orelse return; st.ptys[pane] = null; pt.reader.cancel(st.io) catch {}; _ = libc.close(pt.file.handle); - // A library inside an app that runs for hours cannot leave these: the tty - // shell gets away with never reaping because the process exits seconds - // later, but here it would be one zombie per shell ever opened. NOHANG - // because the child may still be dying and the UI thread must not wait for - // it; the next reap or process exit collects whatever is left. _ = libc.waitpid(pt.pid, null, posix.W.NOHANG); } -/// Drain one pty into its inbox and wake the host. The same shape as the tty -/// shell's reader, with the vaxis event queue replaced by a mutex and one -/// callback: do the blocking thing away from the loop, hand the bytes over, -/// leave the core a state machine that never waits. fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror!void { var read_buf: [0x10000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); @@ -2383,29 +2124,20 @@ fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror! var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; - // Duped outside the lock on purpose — see Inbox. const bytes = st.gpa.dupe(u8, buf[0..n]) catch break; - st.inbox.push(st.gpa, .{ .output = .{ .pane = id, .gen = gen, .bytes = bytes } }); + st.inbox.pushOutput(st.gpa, io, .{ .pane = id, .gen = gen, .bytes = bytes }) catch break; wake(st); } - st.inbox.push(st.gpa, .{ .eof = .{ .pane = id, .gen = gen } }); + st.inbox.push(st.gpa, st.io, .{ .eof = .{ .pane = id, .gen = gen } }); wake(st); } -/// Ask the host for a tick, at most once per tick. `pardes_tick` clears the -/// flag before it drains, so a push that lands mid-drain still wakes and no -/// message can be left sitting in the inbox with nobody scheduled to read it. fn wake(st: *State) void { const cb = st.runtime.wakeup orelse return; if (st.inbox.wake_pending.swap(true, .acq_rel)) return; cb(st.runtime.userdata); } -// ---------------------------------------------------------------- helpers - -/// Rebuild the process environment as a Map, because a library never sees the -/// std.process.Init that main() gets one from. Only the config-path lookup -/// reads it, and the arena owns the copies for the life of the process. fn captureEnv(arena: std.mem.Allocator) ?std.process.Environ.Map { var map: std.process.Environ.Map = .init(arena); const environ = std.c.environ; @@ -2460,10 +2192,6 @@ fn encodeCellFlags(default: bool, role: pardes.FontRole) u8 { @as(u8, @intFromBool(role == .tagline)) * cell_flag_tagline; } -/// Spend accumulated sub-row travel as whole wheel notches, keeping the -/// remainder. The core has no fractional scroll — both other shells do this -/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic -/// fling reported in points, a NaN) cannot spin the emit loop. fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { if (!std.math.isFinite(delta_rows)) return 0; const next = std.math.clamp(lag.* + delta_rows, -256, 256); @@ -2473,55 +2201,20 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { return whole; } -/// One search step per this many degrees of twist. Every notch is a jump to -/// another match, so it stays coarse enough that a thumb resettling cannot -/// walk the cursor across the file — but 20 degrees was more than a wrist -/// gives without thinking about it, and the dial felt stuck. Ten is still a -/// deliberate twist, and 36 steps to a full turn. const rotation_notch_degrees: f32 = 10; -/// Where momentum STARTS, in degrees per second — and it starts at zero. -/// -/// The fling is the release speed MINUS this, so a slow twist coasts not a -/// little but not at all, and the faster the flick the more there is. A plain -/// threshold would hand out two free notches the instant it was crossed, which -/// is the one thing a dial must not do: the same gesture, a hair quicker, -/// jumping twice as far is how a control stops feeling like a control. const rotation_fling_floor: f32 = 70; -/// ...and the ceiling on what is left after that subtraction. AppKit reports a -/// thousand degrees a second for one frame of a twitch, and this cap is what -/// decides how far the hardest possible flick throws the list: 400 deg/s is -/// about 111 degrees of coast, so eleven matches. Twenty read as the list -/// getting away from you. const rotation_fling_max: f32 = 400; -/// One pump of coasting. Fixed rather than measured: the host re-pumps at -/// ~60 Hz for exactly as long as pardes_animating says to, and a fixed step -/// makes one fling spend the same travel every time — which is what lets a -/// golden assert it instead of asserting the machine's timer jitter. const rotation_fling_step: f32 = 1.0 / 60.0; -/// Per-step decay. 0.94 at 60 Hz is a little over half a second of coast, the -/// same order as the trackpad's own inertial scrolling. const rotation_fling_decay: f32 = 0.94; -/// Below this the dial is at rest: one notch a second is not momentum, it is a -/// list still stepping long after the hand has moved on. const rotation_fling_stop: f32 = 18; -/// The velocity a release at `speed` degrees/second actually coasts at, after -/// the floor is subtracted and the remainder capped. Zero means the twist was -/// a placement, not a throw — which is most of them. -/// -/// Total travel follows from it and the decay as a geometric series: -/// `v * step / (1 - decay)`, i.e. about 0.28 degrees per degree/second. A -/// 200 deg/s release therefore coasts ~36 degrees, three or four notches. fn rotationFling(speed: f32) f32 { const excess = @min(@abs(speed) - rotation_fling_floor, rotation_fling_max); if (excess < rotation_fling_stop) return 0; return std.math.copysign(excess, speed); } -/// Spend accumulated rotation as whole search steps, keeping the remainder. -/// Same contract as takeScrollTicks, including the clamp: an absurd delta -/// spends a bounded number of notches instead of spinning the emit loop. fn takeRotationNotches(lag: *f32, degrees: f32) i32 { if (!std.math.isFinite(degrees)) return 0; const limit = rotation_notch_degrees * 64; @@ -2532,17 +2225,6 @@ fn takeRotationNotches(lag: *f32, degrees: f32) i32 { return whole; } -// ---------------------------------------------------------------- ABI guard - -// The header is hand-written, so nothing but a test keeps it honest. build.zig -// translate-C's src/macos/pardes.h into this test build and every constant and -// layout below is asserted against the Zig side — ghostty's trick, and the -// cheapest possible insurance against a silent ABI skew. -/// Compare one declaration's arity and scalar widths against the header's. -/// Not a type equality — translate-C spells pointers `[*c]` and mints its own -/// struct types, so nothing here would ever match exactly. Arity and width are -/// what actually break: a parameter added on one side only (which is how the -/// Swift host first got pardes_scroll wrong), or a u16 that became a u32. fn expectSameAbi(comptime C: type, comptime Z: type) !void { const c_fn = @typeInfo(C).@"fn"; const z_fn = @typeInfo(Z).@"fn"; @@ -2631,9 +2313,6 @@ test "pardes.h matches the Zig boundary" { field.name; try expectEqual(@offsetOf(c.pardes_panel_track_s, c_name), @offsetOf(PanelTrack, field.name)); } - // The attachment struct is a wide one and every field is read by hand on - // the Swift side, so its layout is checked at both ends rather than at the - // two that happen to be easy. try expectEqual(@sizeOf(c.pardes_image_s), @sizeOf(Image)); inline for (@typeInfo(Image).@"struct".fields) |field| try expectEqual(@offsetOf(c.pardes_image_s, field.name), @offsetOf(Image, field.name)); @@ -2647,23 +2326,22 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); - try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(panel_animation.Phase.opening)); - try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(panel_animation.Phase.moving)); - try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(panel_animation.Phase.closing)); - try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(panel_animation.Transition.off)); - try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(panel_animation.Transition.slide)); - try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(panel_animation.Transition.zoom)); - try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(panel_animation.Transition.dissolve)); - try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(panel_animation.Transition.ascii)); - try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(panel_animation.Transition.vertical)); - try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(panel_animation.Transition.edges)); - try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(panel_animation.Transition.fall)); - try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(panel_animation.Transition.wave)); - try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(panel_animation.Transition.curtain)); - try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(panel_animation.Transition.scramble)); - try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(panel_animation.Transition.typewriter)); - - // Every key the host has a name for must be the codepoint the core reads. + try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening)); + try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving)); + try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing)); + try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off)); + try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide)); + try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom)); + try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve)); + try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii)); + try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical)); + try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges)); + try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall)); + try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave)); + try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain)); + try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble)); + try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter)); + try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape); try expectEqual(@as(u21, c.PARDES_KEY_TAB), pardes.Key.tab); @@ -2678,8 +2356,6 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u21, c.PARDES_KEY_PAGE_DOWN), pardes.Key.page_down); try expectEqual(@as(u21, c.PARDES_KEY_DELETE), pardes.Key.delete); - // The mouse ordinals the switch in pardes_mouse decodes are the enum's own - // declaration order; a reorder there is a silent remap of acme's buttons. try expectEqual(c.PARDES_MOUSE_LEFT, @intFromEnum(pardes.Mouse.Button.left)); try expectEqual(c.PARDES_MOUSE_MIDDLE, @intFromEnum(pardes.Mouse.Button.middle)); try expectEqual(c.PARDES_MOUSE_RIGHT, @intFromEnum(pardes.Mouse.Button.right)); @@ -2693,13 +2369,10 @@ test "pardes.h matches the Zig boundary" { try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag)); - // The haptic ordinals pardes_take_haptic returns, against the header's - // names and the core's enum. Three places, checked as one. try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none)); try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec)); try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look)); - // The attribute bits the host decodes, against the encoder that writes them. try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true })); try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true })); try expectEqual(@as(u16, c.PARDES_ATTR_ITALIC), encodeAttrs(.{ .italic = true })); @@ -2712,8 +2385,6 @@ test "pardes.h matches the Zig boundary" { encodeAttrs(.{ .ul = .curly }), ); - // Font role is explicit ABI data, not something the host reconstructs - // from tag colours. Default and role occupy independent bits. try expectEqual(@as(u8, 0), encodeCellFlags(false, .body)); try expectEqual(cell_flag_tagline, encodeCellFlags(false, .tagline)); try expectEqual(cell_flag_default | cell_flag_tagline, encodeCellFlags(true, .tagline)); @@ -2731,9 +2402,6 @@ test "scene effect flags and display clock are compact and independent" { encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), ); - // The clock is TIME now, so the wrap is a duration and the assertion is - // that it wraps without losing the remainder — an f32 `time_seconds` that - // grew without bound would lose sub-millisecond resolution within a day. var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); advanceSceneClock(&st, std.time.ns_per_ms * 5); @@ -2743,10 +2411,6 @@ test "scene effect flags and display clock are compact and independent" { } test "the mac panel ABI hands the shader the core's order verbatim" { - // The host used to re-sort by phase here. It does not any more: the order - // is `panel_animation.paintOrder`, applied once in `Pardes.render`, and - // asserted where it lives (src/pardes.zig). What this host still owes is - // that it copies FAITHFULLY and cannot overrun its fixed ABI array. const source = [_]PanelTrack{ .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, @@ -2796,19 +2460,15 @@ test "colors encode to the three tags the host decodes" { test "sub-row scroll spends whole notches and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; - // Four quarter-row flicks are one row, and not before the fourth. try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(i32, 1), takeScrollTicks(&lag, 0.25)); try expectEqual(@as(f32, 0), lag); - // Direction reverses without the accumulated travel leaking across it. try expectEqual(@as(i32, -2), takeScrollTicks(&lag, -2.5)); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25)); - // Garbage moves nothing and leaves the accumulator usable; a fling far - // past the clamp spends at most one screen and does not spin the caller. lag = 0; try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.inf(f32))); @@ -2819,23 +2479,16 @@ test "sub-row scroll spends whole notches and keeps the remainder" { test "trackpad rotation spends whole search steps and keeps the remainder" { const expectEqual = std.testing.expectEqual; var lag: f32 = 0; - // A twist under one notch moves nothing; crossing it moves exactly one, - // and the overshoot is credited to the next. try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 7)); try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 5)); try expectEqual(@as(f32, 2), lag); - // Reversing spends the residue first, so a twist back is not amplified by - // travel the other direction already banked. try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -12)); try expectEqual(@as(f32, 0), lag); - // One deliberate half-turn is several matches, not a hundred. lag = 0; try expectEqual(@as(i32, 18), takeRotationNotches(&lag, 180)); - // Garbage moves nothing and leaves the dial usable; an absurd delta is - // clamped rather than spinning the emit loop. lag = 0; try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32))); try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32))); @@ -2844,27 +2497,17 @@ test "trackpad rotation spends whole search steps and keeps the remainder" { } test "the dial flings in proportion to the release, and not at all when placed" { - // The whole point of the curve: momentum ramps UP FROM ZERO at the floor - // rather than switching on at it, so no release speed exists where the - // same gesture a hair quicker suddenly jumps several matches further. try std.testing.expectEqual(@as(f32, 0), rotationFling(0)); try std.testing.expectEqual(@as(f32, 0), rotationFling(40)); try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor)); - // Just over the floor is still nothing: what is left has to beat the - // at-rest threshold before it is worth waking the pump for. try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor + 5)); - // ...and past that it is linear in the release speed, both ways. try std.testing.expectEqual(@as(f32, 130), rotationFling(200)); try std.testing.expectEqual(@as(f32, -130), rotationFling(-200)); - // A twitch is capped rather than emptying the list. try std.testing.expectEqual(rotation_fling_max, rotationFling(100_000)); try std.testing.expectEqual(-rotation_fling_max, rotationFling(-100_000)); - // What that buys, in the units a hand feels: total coast is the geometric - // series v*step/(1-decay), so a brisk 200 deg/s release is a few matches - // and the hardest flick the cap allows is bounded well short of a hundred. const travel = struct { fn of(speed: f32) f32 { return @abs(rotationFling(speed)) * rotation_fling_step / (1 - rotation_fling_decay); @@ -2872,45 +2515,26 @@ test "the dial flings in proportion to the release, and not at all when placed" }.of; try std.testing.expect(travel(200) / rotation_notch_degrees < 5); try std.testing.expect(travel(200) / rotation_notch_degrees >= 3); - // ...and the hardest flick a trackpad can report is bounded at about a - // dozen matches. This is the number to change if the dial ever feels like - // it is getting away from the hand. try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12); try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8); } -// The loop, end to end, on the one machine that can run it: the core owns the -// iteration now, so the two things this file used to spell out by hand are -// exactly what a live session has to keep proving. A frame exists because the -// DRAW rendered one — ticks drain work and never render, which is what lets -// AppKit coalesce a burst into a single encoded grid — and elapsed animation -// time is spent only by the display clock, however many times the tick runs. -// -// It really boots: a shell is forked, an inbox drains, effects are performed -// through the vtable. Everything above it is the Swift app, which needs a Mac. test "a live session renders on the draw and animates only on the display clock" { try std.testing.expectEqual(@as(c_int, 0), pardes_init(null, 80, 24)); defer pardes_deinit(); const st = &state.?; - // The spawn effect reached forkpty rather than the core's silent fallback: - // init performs its own drain, before any reader task exists. try std.testing.expect(st.ptys[0] != null); - // A tick drains and performs. It publishes no frame, so ten of them in a - // pty burst cost one render and not ten. _ = pardes_tick(); _ = pardes_tick(); try std.testing.expectEqual(@as(u16, 0), pardes_frame_cols()); try std.testing.expect(pardes_frame_cells() == null); - // The draw is what renders and presents. try std.testing.expectEqual(@as(u32, 80 * 24), pardes_frame()); try std.testing.expectEqual(@as(u16, 80), pardes_frame_cols()); try std.testing.expectEqual(@as(u16, 24), pardes_frame_rows()); try std.testing.expect(pardes_frame_cells() != null); - // Two themes, so the second retarget is a real transition whatever the - // developer's config booted this session wearing. for ([_][]const u8{ "Theme dark", "Theme acme" }) |command| { pardes_command(command.ptr, command.len); _ = pardes_tick(); @@ -2918,16 +2542,12 @@ test "a live session renders on the draw and animates only on the display clock" } try std.testing.expect(pardes_animating()); const step = st.core.chrome_animation.step; - // Input and pty pumps drain work and draws encode it; neither spends a - // frame, which is what keeps a burst of keys from collapsing a ten-frame - // fade into one. _ = pardes_tick(); _ = pardes_frame(); _ = pardes_tick(); _ = pardes_frame(); try std.testing.expectEqual(step, st.core.chrome_animation.step); try std.testing.expectEqual(@as(usize, 0), st.core.in_len); - // Only the display clock spends it, and exactly one frame per call. try std.testing.expect(pardes_animation_tick()); try std.testing.expectEqual(step + 1, st.core.chrome_animation.step); _ = pardes_tick(); diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh deleted file mode 100755 index e9ccebcd..00000000 --- a/src/macos/build-e2e.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/sh -# Link the offscreen end-to-end harness: the app's own Swift shell, plus -# test/macos_e2e.swift as the entry point, against the same libpardes.a. Run it -# through `zig build macos-e2e -Dplatform=macos`, or by hand with the install -# prefix as $1 and the deployment target as $2. -# -# A SECOND BINARY rather than a `--e2e` flag on the app, for two reasons. -# -# Test scaffolding does not ship inside the product. A flag would put the script -# interpreter, the /tmp world-builder and the golden differ into the thing a -# user launches, and would give the app a mode in which it rewrites files under -# /tmp and calls exit() — none of which anyone should be one argv typo away from. -# -# And src/macos/Sources/main.swift holds top-level code, which IS an entry -# point: a module cannot contain both top-level statements and a @main type, so -# the harness could not join that link even if the first reason went away. Every -# other Swift file the app builds from is compiled here, so this link is also -# what proves the shell still compiles as a library rather than as an app. -set -eu - -root=$(cd "$(dirname "$0")/../.." && pwd) -out=${1:-"$root/zig-out"} -# Keep in step with macos_min_version in build.zig, which passes it in. The -# default is only for a by-hand run. Same string the app's own link uses, and -# for the same reason: -target is what decides LC_BUILD_VERSION and turns on -# the availability diagnostics. -minver=${2:-13.0} -lib="$out/lib/libpardes.a" -bin="$out/bin/pardes-macos-e2e" - -[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; } -command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; } - -mkdir -p "$out/bin" -# Bundle.main.resourceURL is the executable directory for this standalone -# harness. Put the same committed source there that the real app installs into -# Contents/Resources, so the shader path is exercised rather than bypassed. -cp "$root/shaders/crt.ci.metal" "$out/bin/crt.ci.metal" - -# -import-objc-header and -lc++ are the app link's, unchanged, and have to stay -# that way: this link exists to exercise the app's link, so anything that -# differs here is something the harness cannot vouch for. -# -# -O for the same reason. A debug build of the CoreText pass and the effect -# drain settles on different timings than a user sees, and `stable` waits on -# exactly those timings. -swiftc -O -target "$(uname -m)-apple-macos$minver" \ - -import-objc-header "$root/src/macos/pardes.h" \ - -o "$bin" \ - "$root/src/macos/Sources/PardesView.swift" \ - "$root/src/macos/Sources/ScenePostprocessor.swift" \ - "$root/src/macos/Sources/FileWatcher.swift" \ - "$root/src/macos/Sources/AppDelegate.swift" \ - "$root/test/macos_e2e.swift" \ - "$lib" -lc++ \ - -framework AppKit -framework CoreText -framework CoreGraphics \ - -framework CoreImage -framework Metal - -echo "built $bin" diff --git a/src/main.zig b/src/main.zig index b08a64da..e4aef61e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,15 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); const pardes = @import("pardes.zig"); -const nested = @import("nested.zig"); +const ninep_io = @import("9p_io.zig"); const is_emscripten = builtin.os.tag == .emscripten; extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void; -// emscripten: std.debug's default threaded-io singleton doesn't run on wasm; -// fail it (init errors surface via emscripten_console_error below). Native -// values match the std defaults. Same shim as the prototype's replay.zig. pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten) null else @@ -19,18 +16,6 @@ pub const std_options_debug_io: std.Io = if (is_emscripten) else std_options_debug_threaded_io.?.io(); -// Every std.log call in the process — ours and every dependency's — funnels -// through this one function. ghostty-vt narrates whatever it does not -// implement in the bytes a child writes to its pty (`debug(stream)`, -// `warning(stream): ignoring unimplemented CSI p`, `debug(kitty_gfx)`; opening -// yazi is worth several lines before it has drawn anything), and in the tty -// shell stderr IS the screen — those land on top of the rendered grid, and in -// the gui/web shells on the console. So drop the libraries at every level: an -// `err` painted over the UI is no better than a debug one. Only pardes' own -// scopes get through, because their messages carry detail the error returns -// don't (gui's SDL_GetError strings, dump's zon parse diagnostic) — .default -// is NOT one of them, ghostty and uucode both log unscoped. Set PARDES_LOG to -// get the real logger back: `PARDES_LOG=1 pardes 2>/tmp/pardes.log`. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( @@ -43,40 +28,16 @@ fn logFn( std.log.defaultLog(level, scope, format, args); } -// A panic must restore the terminal (cooked mode, main screen, mouse off) -// before the trace prints, or it lands garbled in a raw alt screen. recover() -// no-ops unless the vaxis tty is live, so gui/tty share the handler. -// -// Then the same message and trace are appended to `/crashes` -// BEFORE stderr gets them, because stderr is the one place this program cannot -// keep them: see crash.zig. Silent on every failure, so the fallback is -// exactly the behaviour that was here before. pub const panic = if (is_emscripten) std.debug.FullPanic(std.debug.defaultPanic) else std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { - // ONCE. This handler is re-entered whenever something panics while it - // runs, and std's own trace printer does exactly that — `defaultPanic` - // survives its own recursion through a private `panic_stage`, and - // everything up here is in front of that guard. `recover()` is not - // idempotent: it closes the vaxis tty and never clears the global that - // says there is one, so a second call double-closes, which std answers - // with `recoverableOsBugDetected` and an `unreachable` in a Debug - // build. Measured with the crash file in place: one panic left TWO - // records, the real message and then "reached unreachable code" from - // this line under it. if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @import("crash.zig").record(msg); std.debug.defaultPanic(msg, ret_addr); } }.call); -/// Whether this process has already restored its terminal — see `panic` above, -/// and note that `debug.handleSegfault` below shares it: a SIGSEGV raised while -/// the panic handler runs must not double-close either. var recovering: std.atomic.Value(bool) = .init(false); -// Fatal signals (SIGSEGV/SIGILL/SIGBUS/SIGFPE) bypass the panic handler and -// no defer/errdefer ever runs — hook std.debug's segfault path the same way -// so the terminal is restored before the trace prints. pub const debug = if (is_emscripten) struct {} else struct { pub fn handleSegfault(addr: ?usize, name: []const u8, opt_ctx: anytype) noreturn { if (!recovering.swap(true, .seq_cst)) @import("vaxis").recover(); @@ -101,23 +62,18 @@ const help_text = \\ Without it, a pardes started inside a pardes \\ hands its FILE argument to the outer one. This \\ session will not serve its own children either. - \\ --fs serve acme's control filesystem for this session - \\ under $XDG_RUNTIME_DIR/pardes/, and export - \\ PARDES_FS and PARDES_PANE into every pane shell - \\ --fs= ...at instead. Must be absolute; pardes - \\ unmounts it on exit but leaves the directory - \\ --fs9 serve that same tree over 9P2000 on a unix socket - \\ at $XDG_RUNTIME_DIR/pardes-9p-.sock, where - \\ is the session name or this pid. Dial it - \\ with `9p -a ` or mount with `9pfuse`. - \\ Independent of --fs: either, both or neither - \\ --fs9= ...named rather than derived. One path - \\ component: no '/' and nothing empty. Served by - \\ --detach sessions today; the tty and GUI shells - \\ still take --fs only + \\ Explicit session configuration starts a new one. + \\ --9p= name the default 9P socket. Without this flag, + \\ the name is the detached session name or pid. + \\ The socket is under $XDG_RUNTIME_DIR, falling + \\ back to ~/.local/state/pardes. + \\ --9p-tcp= also serve 9P at tcp!!. + \\ --9p-quic= also serve at quic!! (-Dquic=true). + \\ --mount== mount a 9P session or Unix/TCP/QUIC endpoint under /n/. + \\ Repeat for more mounts; os and self are reserved. \\ --detach run this session with NO terminal of its own, - \\ serving frontends over a unix socket beside the - \\ nested-instance one. The core, the panes and the + \\ serving frontends over a unix socket beside its + \\ 9P socket. The core, the panes and the \\ undo history outlive every frontend that attaches \\ --detach= ...named rather than this process's pid, so \\ a frontend can say which session it wants. One @@ -136,18 +92,6 @@ const help_text = \\ ; -/// A MISTAKE AT THE SHELL PROMPT, said in words and nothing else. -/// -/// Every one of these used to be `return error.BadArgs` out of `main`, which -/// std prints as `error: BadArgs` with a RETURN TRACE under it. That reads as a -/// crash — it is the same shape a real panic has — for the most ordinary thing -/// a person can do, which is mistype a flag. It also said `BadArgs` and nothing -/// about WHICH argument, when the site that returned it knew exactly. -/// -/// stderr and not an `+Errors` pane, which is the answer one line down in -/// `Pardes.init`: argv is read before any core exists, and a person who typed -/// a bad flag is looking at the prompt they typed it into rather than at an -/// editor. `--attach`'s refusal three functions down already answers this way. fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { var buf: [1024]u8 = undefined; const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch @@ -156,12 +100,6 @@ fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn { std.process.exit(1); } -/// Built at COMPTIME, because both halves are: `version` comes out of -/// `build.zig.zon` through the options module and `commit` out of `git` at -/// configure time, so there is nothing here to format at runtime and no buffer -/// to size. The commit is in parentheses when there is one and absent -/// otherwise — a build from a tarball says `pardes 0.0.1` and is not lying -/// about a revision it never had. See `pardes.version`/`pardes.commit`. const version_text = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n" else @@ -201,116 +139,108 @@ fn webMain() !void { } fn nativeMain(init: std.process.Init) !void { - // FIRST, before anything can log or panic. std's start code hands the - // debug/log stderr writer the process environ exactly as the kernel laid - // it out beside argv, and `std.debug.lockStderr` scans that block once, - // lazily, for NO_COLOR and CLICOLOR_FORCE. Lazily is the problem: libc - // may have rewritten the block by then. SDL_CreateWindow does, every - // time — `Wayland_ShowWindow` unsets XDG_ACTIVATION_TOKEN, and glibc's - // unsetenv compacts `environ` in place and leaves a null in the slot the - // captured length still counts. `Environ.scan` unwraps that null, so the - // FIRST log line or panic in the gui shell panicked inside the scan, and - // then the panic handler took the same path and deadlocked on the stderr - // lock it was already holding: "attempt to use null value" and a hang, - // with no trace and no message of its own. Every SDL_GetError report in - // gui.zig was that, which is to say unreachable. - // - // Locking and immediately unlocking here does the scan while the block is - // still the one std was given, and memoizes it. Nothing is written. var stderr_probe: [64]u8 = undefined; _ = std.debug.lockStderr(&stderr_probe); std.debug.unlockStderr(); var opts: pardes.Options = .{}; + var mounts: [pardes.filesystem.max_mounts]pardes.filesystem.Mount = undefined; + var mounts_len: usize = 0; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); - // bare `pardes` boots straight into tty mode — and so does a `pardes` - // carrying nothing but flags that say something about the SESSION rather - // than about its layout: --nested is about this session's relationship to - // its parent, --fs is about who may script it, --detach is about who may - // WATCH it, --attach is about whose screen this one is showing, and none of - // the four says anything about what should be on screen. Anything else (a - // FILE, -n, --tty) is layout, and answers this question itself further - // down. - opts.tty_only = for (args[1..]) |a| { - if (!std.mem.eql(u8, a, "--nested") and - !std.mem.eql(u8, a, "--fs") and - !std.mem.startsWith(u8, a, "--fs=") and - !std.mem.eql(u8, a, "--fs9") and - !std.mem.startsWith(u8, a, "--fs9=") and - !std.mem.eql(u8, a, "--detach") and - !std.mem.startsWith(u8, a, "--detach=") and - !std.mem.eql(u8, a, "--attach") and - !std.mem.startsWith(u8, a, "--attach=")) break false; - } else true; - // `--detach[=]`: null when it was not given, so the empty string is - // free to mean "the default name" the way opts.fs uses it for a directory. + var session_only = true; + var new_session = false; + var explicit_tty = false; var detach: ?[]const u8 = null; - // ...and `--attach[=]`, the same shape: null when it was not given, - // and the empty string means "the one session there is" - // (detached_client.resolve) rather than a session with no name. var attach: ?[]const u8 = null; - // Kept RAW until every flag is parsed: classifying it means chdir'ing into - // a directory and recording nothing, and the nested client below still - // needs the word itself to resolve. var positional: ?[:0]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--tty")) { - opts.tty_only = true; + explicit_tty = true; } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) { + session_only = false; + new_session = true; opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{a["--tty-toggle=".len..]}); } else if (std.mem.eql(u8, a, "--tty-toggle")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "--tty-toggle needs a letter after it", .{}); opts.tty_toggle = parseCtrlKey(args[i]) orelse badArgs(init.io, "--tty-toggle wants one letter, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-n")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-n needs a count after it: 1 or 3", .{}); opts.shells = std.fmt.parseInt(u8, args[i], 10) catch badArgs(init.io, "-n takes 1 or 3, not '{s}'", .{args[i]}); } else if (std.mem.eql(u8, a, "-l")) { + session_only = false; + new_session = true; i += 1; if (i >= args.len) badArgs(init.io, "-l needs the path of a dump to load", .{}); opts.load_path = args[i]; - } else if (std.mem.eql(u8, a, "--fs")) { - opts.fs = ""; - } else if (std.mem.startsWith(u8, a, "--fs=")) { - // `--fs=` and NEVER `--fs `, which is the one place this - // parser cannot follow --tty-toggle: --tty-toggle's argument is - // mandatory, so consuming the next word is unambiguous. `--fs` is - // useful bare, so a two-word form would make `pardes --fs README` - // mount at ./README and open no file — the flag would silently eat - // the FILE argument. One spelling, and it carries its own value. - opts.fs = a["--fs=".len..]; - } else if (std.mem.eql(u8, a, "--fs9")) { - opts.fs9 = ""; - } else if (std.mem.startsWith(u8, a, "--fs9=")) { - // One spelling that carries its own value, for the reason `--fs` - // gives directly above: the flag is useful bare, so a two-word - // form would make `pardes --fs9 README` a socket called README - // that opens no file. - opts.fs9 = a["--fs9=".len..]; + } else if (std.mem.eql(u8, a, "--9p")) { + badArgs(init.io, "--9p needs a socket name: --9p=", .{}); + } else if (std.mem.startsWith(u8, a, "--9p=")) { + new_session = true; + const name = a["--9p=".len..]; + if (name.len == 0 or std.mem.indexOfAny(u8, name, "/\x00") != null) + badArgs(init.io, "invalid 9P socket name: '{s}'", .{name}); + opts.ninep_name = name; + } else if (std.mem.startsWith(u8, a, "--9p-tcp=")) { + new_session = true; + const dial = a["--9p-tcp=".len..]; + if (!std.mem.startsWith(u8, dial, "tcp!")) + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + if (opts.ninep_tcp != null) badArgs(init.io, "--9p-tcp was specified twice", .{}); + opts.ninep_tcp = dial; + } else if (std.mem.eql(u8, a, "--9p-tcp")) { + badArgs(init.io, "--9p-tcp needs tcp!!", .{}); + } else if (std.mem.startsWith(u8, a, "--9p-quic=")) { + new_session = true; + const dial = a["--9p-quic=".len..]; + if (!std.mem.startsWith(u8, dial, "quic!")) + badArgs(init.io, "--9p-quic needs quic!!", .{}); + _ = @import("9p_io.zig").networkAddress(dial, true) catch + badArgs(init.io, "--9p-quic needs quic!!", .{}); + if (opts.ninep_quic != null) badArgs(init.io, "--9p-quic was specified twice", .{}); + if (!@import("9p_io.zig").quic_enabled) + badArgs(init.io, "QUIC is not included; rebuild with -Dquic=true", .{}); + opts.ninep_quic = dial; + } else if (std.mem.eql(u8, a, "--9p-quic")) { + badArgs(init.io, "--9p-quic needs quic!!", .{}); + } else if (std.mem.startsWith(u8, a, "--mount=")) { + new_session = true; + const mount = a["--mount=".len..]; + const split = std.mem.indexOfScalar(u8, mount, '=') orelse + badArgs(init.io, "--mount needs name=dial", .{}); + const name = mount[0..split]; + const dial = mount[split + 1 ..]; + if (dial.len == 0 or !@import("fs.zig").validMountName(name)) + badArgs(init.io, "invalid mount name or dial: '{s}'", .{mount}); + @import("9p_io.zig").Client.validateDial(dial) catch + badArgs(init.io, "invalid mount dial: '{s}'", .{dial}); + for (mounts[0..mounts_len]) |existing| if (std.mem.eql(u8, existing.name, name)) + badArgs(init.io, "duplicate mount name: '{s}'", .{name}); + if (mounts_len == mounts.len) badArgs(init.io, "too many mounts (maximum {d})", .{mounts.len}); + mounts[mounts_len] = .{ .name = name, .dial = dial }; + mounts_len += 1; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; } else if (std.mem.eql(u8, a, "--detach")) { detach = ""; } else if (std.mem.startsWith(u8, a, "--detach=")) { - // `--detach=` and never `--detach `, for exactly the - // reason --fs gives above: the flag is useful bare, so a two-word - // form would make `pardes --detach README` a session called README - // that opens no file. detach = a["--detach=".len..]; } else if (std.mem.eql(u8, a, "--attach")) { attach = ""; } else if (std.mem.startsWith(u8, a, "--attach=")) { - // `--attach=` and never `--attach `, for the reason - // --fs states above and --detach repeats: the flag is useful bare, - // so a two-word form would make `pardes --attach README` an attach - // to a session called README that opens no file. attach = a["--attach=".len..]; } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { try std.Io.File.stdout().writeStreamingAll(init.io, help_text); @@ -319,6 +249,7 @@ fn nativeMain(init: std.process.Init) !void { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; } else if (a.len > 0 and a[0] != '-' and positional == null) { + session_only = false; positional = a; } else if (a.len == 0) { badArgs(init.io, "an empty argument names nothing", .{}); @@ -328,170 +259,86 @@ fn nativeMain(init: std.process.Init) !void { badArgs(init.io, "one file or directory at a time, and '{s}' is the second", .{a}); } } - // Started INSIDE another pardes: hand it the file and get out of the way - // rather than stacking a second full-screen UI inside one of its panes. - // The word is resolved here rather than sent raw because the outer - // instance resolves against ITS panes' directories, which are not ours. - // A word naming nothing on disk is REFUSED HERE, in this shell, and does - // not fall through: the classification below used to refuse it too, and - // once it started booting an `+Errors` pane instead, a typo became the one - // input that stacked the second full-screen UI this whole block exists to - // prevent — and one with no shell pane in it, so the only way out is `Del`. - // The outer instance is not told either: `Look` on a word naming nothing - // is not something to do to somebody else's session. - // - // `--detach` is exempt for the same reason `--nested` is, arrived at from - // the other side: it stacks no UI at all. A detached session started from a - // pane is a session, not a request that the outer instance open something, - // and handing it our positional would leave the caller with no session. - // - // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI - // is a session that already exists somewhere else, and handing our word to - // the outer instance would open the file in the WRONG session and leave - // the caller with no frontend. - if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| { + opts.tty_only = explicit_tty or session_only; + opts.mounts = mounts[0..mounts_len]; + if (detach != null and attach != null) + badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); + if (opts.ninep_name.len != 0 and attach != null) + badArgs(init.io, "--9p names a session's own socket, and --attach has none of its own", .{}); + if (opts.ninep_tcp != null and attach != null) + badArgs(init.io, "--9p-tcp opens a session's own listener, and --attach has none of its own", .{}); + if (opts.ninep_quic != null and attach != null) + badArgs(init.io, "--9p-quic opens a session's own listener, and --attach has none of its own", .{}); + if (opts.mounts.len != 0 and attach != null) + badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); + if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { + const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; + if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); + ninep_io.Client.validateDial(dial) catch break :forwarding; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; + if (serial == 0) break :forwarding; + var ctl_buf: [64]u8 = undefined; + const ctl = try std.fmt.bufPrint(&ctl_buf, "/self/pane/{d}/ctl", .{serial}); const word = positional orelse { + var tag_buf: [64]u8 = undefined; + const tag = try std.fmt.bufPrint(&tag_buf, "/self/pane/{d}/tag", .{serial}); + const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; - var cwdbuf: [4096]u8 = undefined; - const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; + const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; - const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { - .dir => |d| nested.sendLook(outer_pid, d, 0), - .file => |t| nested.sendLook(outer_pid, t.path, t.at.line), - .image => |t| nested.sendLook(outer_pid, t.path, 0), - // Nothing of that name. One line on this shell's stderr and out, - // which is what the paragraph above promises: the outer session is - // not disturbed and no UI is stacked. Said in words rather than - // returned as an error, because an error out of `main` is the - // stack trace this release stopped showing people for a typo. - .none => { - var buf: [4096]u8 = undefined; - const line = std.fmt.bufPrint(&buf, "pardes: file or directory not found: {s}\n", .{word}) catch "pardes: file or directory not found\n"; - try std.Io.File.stderr().writeStreamingAll(init.io, line); - std.process.exit(1); - }, - // an unreachable outer instance (an older build, a stale socket - // path) is not worth failing a launch over: run normally instead - else => false, - }; - if (sent) return; - }; + 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, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + return; + } if (positional) |a| { - // a directory becomes the cwd shells spawn in (chdir succeeds only on - // dirs); anything else resolves as a file - if (std.c.chdir(a.ptr) != 0) { + const target = @import("look.zig").parsePathLine(a); + if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or + std.mem.eql(u8, target.path, "/virtual") or std.mem.startsWith(u8, target.path, "/virtual/")) + { + opts.file = try arena.dupe(u8, target.path); + opts.file_line = target.at.line; + } else if (std.c.chdir(a.ptr) != 0) { var cwdbuf: [4096]u8 = undefined; const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse - badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); + badArgs(init.io, "this shell's working directory is gone; cd somewhere that exists", .{}); var realbuf: [4096]u8 = undefined; - switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { + switch (@import("look.zig").resolve(null, a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { .file => |t| { opts.file = try arena.dupe(u8, t.path); opts.file_line = t.at.line; }, .image => |t| opts.file = try arena.dupe(u8, t.path), - // Nothing of that name is there. A typo is not a reason to - // refuse to start: the session boots with one `+Errors` pane - // naming what was asked for (pardes.zig `missing`). - // - // The LAUNCH DIRECTORY goes with it, and it is not decoration: - // an output pane's directory is where a `Grep` from it walks, - // where a `Newtty` spawns its shell and what a `Save` prefills. - // The first draft passed "" — copied from the board's boot - // buffer, which can afford it because that platform has no - // filesystem — and the pane came out at `/+Errors`, so `Grep` - // on the boot screen walked from the root of the filesystem. - // - // The other arms stay `BadArgs`. `.url` and `.pane` are targets - // no LAUNCH can act on, and `.dir` here is a directory that - // resolves but `chdir` refused, which is a permission problem - // rather than a typo. NOTE that the commonest permission case - // does not arrive here at all: `look.isDir` probes with - // `O_DIRECTORY|O_RDONLY`, so a directory you cannot read (say - // `/root`) fails that probe, resolves as `.file`, and dies in - // `file_pane.open` with `error.OpenFailed` out of `main` — - // still a stack trace at a human. Left as it was, because it is - // a different fault than the one this arm fixes. .none => opts.missing = .{ .word = try arena.dupe(u8, a), .dir = try arena.dupe(u8, std.mem.span(@as([*:0]u8, @ptrCast(cwd)))), }, - // A URL, an `@pN` pane address, or a directory that resolves - // and `chdir` refused. None is a typo, and none is something a - // LAUNCH can act on — the first two are words to click once - // pardes is open, and the third is a permission problem. .url => badArgs(init.io, "a URL is not something a launch can open; start pardes and click it", .{}), .pane => badArgs(init.io, "@pN addresses a pane of a running pardes, so there is none yet", .{}), else => badArgs(init.io, "cannot enter that directory: {s}", .{a}), } } } - // Native shells opt into the user config; direct core callers and web keep - // Options' null default. Read it before entering either frontend so every - // builtin has run before that frontend can render its first frame. - const found = @import("user_config.zig").load(init.io, arena, init.environ_map); + const found = pardes.config.User.load(init.io, arena, init.environ_map); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; - // The panic handler above writes beside that init file, and this is the - // only place it can learn where that is — it runs with no `Options` in - // reach. Set for every native entry through this file, including the - // `--detach` daemon below, whose stderr nobody is reading. if (found.dir) |d| @import("crash.zig").setDir(d); - // `--detach` is the core with no terminal and `--attach` is a terminal - // with no core, so the two together are a contradiction with no useful - // reading. Refused rather than resolved by declaration order, which would - // silently drop whichever flag lost. - if (detach != null and attach != null) - badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); - // `--fs` mounts the acme control filesystem, and it needs a CORE to serve. - // `--attach` has none — it is a terminal whose state lives in another - // process — so the flag there would be parsed, stored, and served by - // nobody. Refused rather than dropped, and it is the stronger case of the - // line above: a contradiction is at least visible, whereas a silently - // dropped mount is invisible until someone waits for a directory that will - // never appear. - // - // `--detach` used to be refused here too, and is not any more. The reason - // given was that `push_fs_reply` was one of the host methods the detached - // core deliberately left null; it no longer is. A daemon mounts its own - // /dev/fuse and polls it in the same `poll(2)` as its frontends and its - // pane shells, which costs it one descriptor and no thread — strictly less - // than the desktop shells pay. `--detach --fs` is now the configuration - // that most wants a control filesystem, because it is the one whose panes - // outlive every terminal that could otherwise have scripted them. - if (opts.fs != null and attach != null) - badArgs(init.io, "--fs serves a session's own core, and --attach has none of its own", .{}); - // ...and `--fs9` for exactly that reason and no other: it is the same tree - // over a different transport, and an `--attach` has no core to serve it - // from either. Checked separately rather than folded into the line above - // so that neither flag's refusal is a side effect of the other's — they - // are independent everywhere else. - if (opts.fs9 != null and attach != null) - badArgs(init.io, "--fs9 serves a session's own core, and --attach has none of its own", .{}); - // `--detach` replaces the frontend rather than choosing among them: the - // core runs here, with no terminal, and the frontends are elsewhere on a - // socket (src/detached/). It is checked before `platform` because it is not - // a shell — the tty and gui builds can both be asked for one. if (detach) |name| { - // Bare `--detach` is named by this process's pid, which is the one name - // nobody has to be told and no two sessions can share. Unsigned: `{d}` - // prints a leading '+' for a positive SIGNED int, which is nested.zig's - // note about the same cast. const named = if (name.len != 0) name else try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))}); return @import("detached/server.zig").run(init, opts, named); } - // A frontend is a SHELL, and the two native ones — a terminal and an SDL - // window — both know how to be one. The browser has no unix socket to - // reach a session over and the AppKit shell is entered by its own host - // rather than through this file, so neither is wired for it; the check is - // comptime-folded, so a tty or gui build carries none of it. if (attach != null and pardes.platform != .tty and pardes.platform != .gui) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty or gui shell\n"); std.process.exit(1); @@ -499,11 +346,6 @@ fn nativeMain(init: std.process.Init) !void { switch (pardes.platform) { .tty => try @import("tty/tty.zig").run(init, opts, attach), .gui => try @import("gui/gui.zig").run(init, opts, attach), - // Every other shell is entered by its host and never links this file - // at all: the browser through src/web.zig, the macOS app through - // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig, - // which is a root of its own in this repository and links the - // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig. .web, .macos, .esp32p4 => unreachable, } } @@ -523,65 +365,22 @@ fn parseCtrlKey(raw: []const u8) ?u21 { return c; } -// The shells are imported inside main(), which a test build never analyses — -// so their inline tests need naming here to exist at all. Each shell only -// compiles when selected (GUI @cImports SDL; TTY imports vaxis), hence the -// comptime gates. Naming a file gets THAT file's tests and no further: -// fonts.zig is imported by gui.zig, builtins.zig and the macOS host, and still -// needs its own line here. test { - _ = @import("user_config.zig"); - // Reached only from the panic handler and from `nativeMain`, neither of - // which a test build analyses — so without this line the crash file has no - // test at all. + _ = pardes.config.User; _ = @import("crash.zig"); - _ = @import("allocators.zig"); - _ = @import("fs_service.zig"); - // acme's control filesystem, both halves, and NOT their own b.addTest - // modules in build.zig the way temp_file/nested/fonts are: both reach - // src/pardes.zig (acmefs takes a *Pardes, fuse.zig speaks its Req/Reply), - // so a standalone module would have to re-wire ghostty-vt, tree-sitter, the - // themes and every option the core imports. This module already has them. - // - // fuse.zig genuinely needs its name here (nothing the core analyses reaches - // it — only the shells import it). acmefs.zig does not today, because - // pardes.zig re-exports it unconditionally; it is named anyway, because the - // day that re-export grows a comptime gate is the day 23 tests disappear in - // silence. That is the fonts.zig story above, told once already. - _ = @import("acmefs.zig"); - _ = @import("fuse.zig"); - // The detached-session transport (src/detached/), same story as fuse.zig - // above: it speaks the core's Event/Surface, so it belongs in THIS module - // rather than a standalone b.addTest, and nothing the core analyses reaches - // it. A TTY build does — tty.zig imports client.zig for `--attach` — but - // these names are what makes the transport's tests exist in every other - // build too, and `--detach` is not a tty-only feature. - // client.zig's own tests drive a real `Session` over a real socket, so - // naming it reaches server.zig too — but server.zig is named anyway, for - // the acmefs.zig reason: the day client.zig stops importing it is the day - // those tests vanish in silence. + _ = @import("memory.zig"); + _ = @import("fs.zig"); _ = @import("detached/wire.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); - // The 9P listener, and it needs its name here for the reason the - // panel_compositor line below states rather than the fuse.zig one above: - // detached/server.zig imports it, but naming a file does not make Zig - // analyse the tests of what IT imports — measured, by three tests that - // compiled and never ran. A standalone b.addTest is not an option either, - // because this file reaches pardes.zig (`Server(acmefs)`); src/9p.zig, the - // half that does NOT, has one in build.zig. - _ = @import("fs9_service.zig"); - // Its client twin, and it needs its name here for the same reason with the - // same measurement behind it: builtins.zig imports it for the `9p` word, - // and naming a file does not make Zig analyse the tests of what IT - // imports. The protocol half's tests are in src/9p.zig's own b.addTest; - // these are the host's — the argument split, the two dial spellings, and - // the immediate refusal when nothing is listening. - _ = @import("fs9_client.zig"); + _ = @import("9p_io.zig"); + _ = @import("9p_io.zig").Client; + _ = @import("host_io.zig"); + _ = @import("host_io.zig").Shell; + _ = @import("host_io.zig").Lsp; + _ = @import("lsp/lsp_client.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); - // tty.zig calls the compositor only from its runtime loop, so merely - // naming the shell does not make Zig analyse the compositor's tests. _ = @import("tty/panel_compositor.zig"); } if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); diff --git a/src/memory.zig b/src/memory.zig new file mode 100644 index 00000000..60ec1bb6 --- /dev/null +++ b/src/memory.zig @@ -0,0 +1,148 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const config = @import("pardes_config"); + +const board = config.platform == .esp32p4; +const reduced_target = builtin.os.tag == .freestanding; +const KiB = 1024; +const MiB = 1024 * KiB; + +pub const limits = struct { + pub const max_file_bytes = 256 * MiB; + pub const max_stream_bytes = 4 * MiB; + // P4: 384 KiB heap; static buffers and stack share a separate 240 KiB region. + pub const board_heap_bytes = 384 * KiB; + pub const effect_cap = if (board) 128 else 4096; + pub const pending_write_cap: usize = if (board) 0 else 4 * MiB; + pub const wrap_rows = if (board) 128 else 256; + pub const undo_max = if (board) 16 else 256; + pub const message_log = if (board) 16 else 128; + pub const max_tag_tail: usize = if (board) 512 else 4096; + pub const host_path_cap: usize = if (board) 0 else 4095; + pub const embedded_sources = !board; + pub const hexdump_row_bytes: u32 = if (board) 8 else 16; + + pub const arena = struct { + pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB; + pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB; + pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB; + pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB; + pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB; + }; +}; + +const Allocator = std.mem.Allocator; +const debug_enabled = builtin.mode == .Debug; + +pub const Allocators = struct { + pardes: Allocator, + frame: Allocator, + lsp: Allocator, + tree_sitter: Allocator, + image: Allocator, + pdf: Allocator, +}; + +var pardes_fallback: std.heap.StackFallbackAllocator(limits.arena.pardes) = undefined; +var frame_fallback: std.heap.StackFallbackAllocator(limits.arena.frame) = undefined; +var tree_sitter_fallback: std.heap.StackFallbackAllocator(limits.arena.tree_sitter) = undefined; +var image_fallback: std.heap.StackFallbackAllocator(limits.arena.image) = undefined; +var pdf_fallback: std.heap.StackFallbackAllocator(limits.arena.pdf) = undefined; + +const Debug = std.heap.DebugAllocator(.{}); +var pardes_debug: Debug = .init; +var frame_debug: Debug = .init; +var lsp_debug: Debug = .init; +var tree_sitter_debug: Debug = .init; +var image_debug: Debug = .init; +var pdf_debug: Debug = .init; + +// One session at a time; free its allocations before deinit. Concurrent LSP workers use the caller's allocator. +pub fn init(fallback: Allocator) Allocators { + pardes_fallback.fallback_allocator = fallback; + pardes_fallback.get_called = if (std.debug.runtime_safety) false else {}; + frame_fallback.fallback_allocator = fallback; + frame_fallback.get_called = if (std.debug.runtime_safety) false else {}; + tree_sitter_fallback.fallback_allocator = fallback; + tree_sitter_fallback.get_called = if (std.debug.runtime_safety) false else {}; + image_fallback.fallback_allocator = fallback; + image_fallback.get_called = if (std.debug.runtime_safety) false else {}; + pdf_fallback.fallback_allocator = fallback; + pdf_fallback.get_called = if (std.debug.runtime_safety) false else {}; + + const raw: Allocators = .{ + .pardes = pardes_fallback.get(), + .frame = frame_fallback.get(), + .lsp = fallback, + .tree_sitter = tree_sitter_fallback.get(), + .image = image_fallback.get(), + .pdf = pdf_fallback.get(), + }; + if (!debug_enabled) return raw; + + pardes_debug = .{ .backing_allocator = raw.pardes }; + frame_debug = .{ .backing_allocator = raw.frame }; + lsp_debug = .{ .backing_allocator = raw.lsp }; + tree_sitter_debug = .{ .backing_allocator = raw.tree_sitter }; + image_debug = .{ .backing_allocator = raw.image }; + pdf_debug = .{ .backing_allocator = raw.pdf }; + return .{ + .pardes = pardes_debug.allocator(), + .frame = frame_debug.allocator(), + .lsp = lsp_debug.allocator(), + .tree_sitter = tree_sitter_debug.allocator(), + .image = image_debug.allocator(), + .pdf = pdf_debug.allocator(), + }; +} + +pub fn deinit() void { + if (!debug_enabled) return; + var leaked = pardes_debug.deinit() == .leak; + leaked = (frame_debug.deinit() == .leak) or leaked; + leaked = (lsp_debug.deinit() == .leak) or leaked; + leaked = (tree_sitter_debug.deinit() == .leak) or leaked; + leaked = (image_debug.deinit() == .leak) or leaked; + leaked = (pdf_debug.deinit() == .leak) or leaked; + if (leaked) @panic("allocator leaks detected"); +} + +test "fixed allocators are separate, spill, and restart" { + var allocs = init(std.testing.allocator); + const core = try allocs.pardes.alloc(u8, 32); + const frame = try allocs.frame.alloc(u8, 32); + try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(core.ptr)); + try std.testing.expect(frame_fallback.fixed_buffer_allocator.ownsPtr(frame.ptr)); + try std.testing.expect(core.ptr != frame.ptr); + + const spill = try allocs.pardes.alloc(u8, limits.arena.pardes + 1); + try std.testing.expect(!pardes_fallback.fixed_buffer_allocator.ownsPtr(spill.ptr)); + allocs.pardes.free(spill); + allocs.frame.free(frame); + allocs.pardes.free(core); + deinit(); + + allocs = init(std.testing.allocator); + defer deinit(); + const restarted = try allocs.pardes.alloc(u8, 32); + defer allocs.pardes.free(restarted); + try std.testing.expect(pardes_fallback.fixed_buffer_allocator.ownsPtr(restarted.ptr)); +} + +test "memory limits preserve desktop capacities" { + if (board or reduced_target) return error.SkipZigTest; + try std.testing.expectEqual(4096, limits.effect_cap); + try std.testing.expectEqual(@as(usize, 4 * MiB), limits.pending_write_cap); + try std.testing.expectEqual(256, limits.wrap_rows); + try std.testing.expectEqual(256, limits.undo_max); + try std.testing.expectEqual(128, limits.message_log); + try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); + try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); + try std.testing.expect(limits.embedded_sources); + try std.testing.expectEqual(@as(u32, 16), limits.hexdump_row_bytes); + try std.testing.expectEqual(32 * MiB, limits.arena.pardes); + try std.testing.expectEqual(16 * MiB, limits.arena.frame); + try std.testing.expectEqual(16 * MiB, limits.arena.tree_sitter); + try std.testing.expectEqual(32 * MiB, limits.arena.image); + try std.testing.expectEqual(if (config.mupdf) 64 * MiB else 64 * KiB, limits.arena.pdf); +} diff --git a/src/message.zig b/src/message.zig deleted file mode 100644 index 0855c448..00000000 --- a/src/message.zig +++ /dev/null @@ -1,88 +0,0 @@ -//! Native-shell ownership of the transient message row's one clock read. -//! -//! The core owns the row, the buffer and when it goes away (Pardes.setMessage); -//! what it does not own is a clock, and its header says so. So what happened is -//! narrated HERE — at the moment the IO it narrates actually finished, which is -//! also the only moment it is true: a save that failed says nothing — and handed -//! over as finished text. Both native shells post the same two events (a save, -//! an external reload), so the wording sits in one place instead of once per -//! shell, where the two copies would drift the first time either was reworded. -//! Same split, and the same reason, as temp_file.zig owning `New`'s mkstemp. -//! -//! LOCAL time, not UTC: this row is read by a person sitting in front of the -//! terminal. dump.zig stamps filenames in UTC because those are sorted, not -//! read. -const std = @import("std"); -const libc = std.c; - -/// glibc/musl/macOS `struct tm`. Only the first three fields are ever read; the -/// rest are declared because localtime_r writes the whole struct. -const Tm = extern struct { - sec: c_int, - min: c_int, - hour: c_int, - mday: c_int, - mon: c_int, - year: c_int, - wday: c_int, - yday: c_int, - isdst: c_int, - gmtoff: c_long, - zone: ?[*:0]const u8, -}; -extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; - -/// `HH:MM:SS ` into `buf`, e.g. `14:32:07 saved /etc/hosts`. -/// Written into a caller buffer rather than allocated, because the core's own -/// message buffer is fixed too and a message wider than a pane is one nobody -/// reads. A subject too long for the room left is cut from the FRONT: the tail -/// of a path is the part that identifies it. -/// -/// $PARDES_NOTIME blanks the DIGITS and nothing else. The snapshot harness sets -/// it (the same flag hides the language backend's durations) because a wall -/// clock cannot live in a golden — but a message that vanished under the -/// harness could not be pinned at all, so the row still renders, still says -/// what happened, and still occupies the same columns. -/// The stamped text WITHOUT its clock — what two of these rows have in common -/// when they say the same thing at different times. -/// -/// The message log de-duplicates on this rather than on the whole row, because -/// the clock makes every host message unique by construction: `saved x` at -/// 14:32:07 and at 14:32:09 are different strings, so a watched file rebuilt -/// in a loop filled the ring with identical-looking rows, each counted once. -/// That is exactly the case the de-duplication exists for. -pub fn body(text: []const u8) []const u8 { - // `HH:MM:SS ` — ten bytes, digits or the `--:--:--` the harness blanks - // them to. Anything else is a message that was never stamped. - if (text.len < 10) return text; - if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; - for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { - if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; - } - return text[10..]; -} - -pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { - var clock: [8]u8 = "--:--:--".*; - const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; - if (!notime) { - var ts: libc.timespec = undefined; - _ = libc.clock_gettime(.REALTIME, &ts); - const secs: libc.time_t = ts.sec; - var tm: Tm = undefined; - if (localtime_r(&secs, &tm) != null) { - // unsigned on purpose: Zig 0.16 prints a `+` for a positive SIGNED - // int, and `{d:0>2}` on a c_int would spell 14:32:07 as +1:+3:+7 - _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ - @as(u32, @intCast(tm.hour)), - @as(u32, @intCast(tm.min)), - @as(u32, @intCast(tm.sec)), - }) catch {}; - } - } - const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; - const room = buf.len - head.len; - const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; - @memcpy(buf[head.len..][0..tail.len], tail); - return buf[0 .. head.len + tail.len]; -} diff --git a/src/modal.zig b/src/modal.zig index 11eae743..3827d11f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -1,14 +1,654 @@ const std = @import("std"); const uucode = @import("uucode"); -// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested -// in isolation (see the `unit-test` build step). main.zig wires this onto the -// pane's cursor + (for file panes) its content. -// -// The cursor sits ON a grapheme: col is its UTF-8 byte offset in -// [0, line.len]; col == line.len means "on the line terminator / after the -// last grapheme". Motions never leave a cursor in the middle of UTF-8 or an -// extended grapheme cluster. +pub const Normal = struct { + pub const Role = enum { + escape, + + prefix_goto, + prefix_view, + prefix_match, + prefix_find_fwd, + prefix_find_back, + prefix_till_fwd, + prefix_till_back, + prefix_replace, + prefix_next, + prefix_prev, + + goto_file_start, + goto_last_line, + goto_line_start, + goto_line_end, + goto_first_nonws, + goto_line_down, + goto_line_up, + goto_column, + goto_view_top, + goto_view_center, + goto_view_bottom, + goto_definition, + goto_declaration, + goto_type_definition, + goto_implementation, + goto_references, + + view_top, + view_center, + view_bottom, + view_scroll_down, + view_scroll_up, + + match_inside, + match_around, + surround_add, + surround_replace, + surround_delete, + + goto_paragraph, + add_newline, + goto_diagnostic, + goto_diagnostic_end, + + move_left, + move_right, + move_down, + move_up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, + repeat_find, + line_start, + line_end, + line_first_nonws, + goto_line, + half_page_down, + half_page_up, + page_down, + page_up, + + insert, + append, + insert_line_start, + insert_line_end, + open_below, + open_above, + + select_mode, + select_line, + select_line_bounds, + shrink_to_line_bounds, + collapse_selection, + flip_selection, + select_all, + copy_sel_below, + copy_sel_above, + keep_primary_sel, + remove_primary_sel, + rotate_sel_fwd, + rotate_sel_back, + split_sel_newline, + merge_sels, + merge_consecutive_sels, + trim_sels, + select_regex, + split_regex, + + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + to_lowercase, + to_uppercase, + join_lines, + indent, + unindent, + format, + increment, + decrement, + comment_toggle, + undo, + redo, + + leader, + command_line, + pipe_selection, + pipe_selection_to, + insert_output, + append_output, + search, + search_next, + search_prev, + }; + + pub const Input = struct { + roles: std.EnumSet(Role) = .initEmpty(), + cp: u21, + ctrl: bool = false, + alt: bool = false, + + pub fn has(value: Input, role: Role) bool { + return value.roles.contains(role); + } + + fn literal(value: Input) ?u21 { + if (value.ctrl or value.alt or value.cp >= 0xF0000) return null; + return value.cp; + } + }; + + pub const Prefix = enum(u8) { + none, + goto, + view, + match, + find_fwd, + find_back, + till_fwd, + till_back, + replace, + next, + prev, + }; + + pub const MatchSub = enum(u8) { + none, + inside, + around, + surround_add, + surround_replace, + surround_delete, + }; + + pub const State = struct { + count: u32 = 0, + prefix: Prefix = .none, + match_sub: MatchSub = .none, + held_char: u21 = 0, + + pub fn clear(state: *State) void { + state.* = .{}; + } + }; + + pub const PipeBehavior = enum { + /// `|` — stdin is the selection, and the output REPLACES it. + replace, + /// `A-|` — stdin is the selection, and the output is discarded. The text + /// is not touched at all; the point is the command's side effect. + ignore, + /// `!` — no stdin, and the output is inserted BEFORE each selection. + insert, + /// `A-!` — no stdin, and the output is appended AFTER each selection. + append, + + /// Do the selections become stdin? helix's `pipe` flag. + pub fn pipes(b: PipeBehavior) bool { + return b == .replace or b == .ignore; + } + }; + + pub const Scope = enum { once, per_selection }; + pub const Direction = enum { backward, forward }; + pub const Motion = enum { + left, + right, + down, + up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, + }; + pub const Goto = enum { + file_start, + last_line, + line_start, + line_end, + first_nonws, + line_down, + line_up, + column, + view_top, + view_center, + view_bottom, + }; + pub const View = enum { top, center, bottom, scroll_down, scroll_up }; + pub const Find = enum { forward, backward, till_forward, till_backward }; + pub const Line = enum { start, end, first_nonws }; + pub const Page = enum { half_down, half_up, down, up }; + pub const Insert = enum { at, append, line_start, line_end, open_below, open_above }; + pub const Select = enum { + mode, + line, + line_bounds, + shrink_to_line_bounds, + collapse, + flip, + all, + }; + pub const Multi = enum { + copy_below, + copy_above, + keep_primary, + remove_primary, + rotate_forward, + rotate_backward, + split_newline, + merge, + merge_consecutive, + trim, + }; + pub const Edit = enum { + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + lowercase, + uppercase, + join_lines, + indent, + unindent, + comment_toggle, + undo, + redo, + }; + pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format }; + + pub const Counted = struct { + count: u32, + explicit: bool, + }; + + pub const Action = union(enum) { + escape, + goto: struct { target: Goto, count: u32, explicit_count: bool }, + view: View, + find: struct { kind: Find, char: u21, count: u32 }, + replace_char: u21, + match_bracket, + textobject: struct { char: u21, around: bool }, + surround_add: u21, + surround_delete: u21, + surround_replace: struct { from: u21, to: u21 }, + paragraph: struct { direction: Direction, count: u32 }, + add_newline: struct { direction: Direction, count: u32 }, + diagnostic: struct { direction: Direction, endpoint: bool }, + move: struct { motion: Motion, count: u32 }, + repeat_find: u32, + line: Line, + goto_line: Counted, + page: struct { kind: Page, count: u32 }, + insert: struct { kind: Insert, count: u32 }, + select: struct { kind: Select, count: u32 }, + multi: struct { kind: Multi, count: u32 }, + select_regex: bool, // false = select, true = split + edit: struct { kind: Edit, count: u32 }, + lsp: Lsp, + adjust_number: i64, + leader, + command_line, + pipe_selection: PipeBehavior, + search, + search_step: Direction, + + pub fn scope(value: Action) Scope { + return switch (value) { + .escape, + .multi, + .select_regex, + .leader, + .command_line, + .pipe_selection, + .search, + .search_step, + => .once, + .edit => |edit| switch (edit.kind) { + .comment_toggle, .undo, .redo => .once, + else => .per_selection, + }, + else => .per_selection, + }; + } + }; + + pub const Result = union(enum) { + pending, + ignored, + unbound, + action: Action, + }; + + fn resultAction(value: Action) Result { + return .{ .action = value }; + } + + fn consumeCount(state: *State) Counted { + const count = state.count; + state.count = 0; + return .{ .count = @max(1, count), .explicit = count != 0 }; + } + + fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result { + state.prefix = prefix; + state.count = saved_count; + if (prefix == .match) { + state.match_sub = .none; + state.held_char = 0; + } + return .pending; + } + + pub fn parse(state: *State, key: Input) Result { + if (key.has(.escape)) { + state.clear(); + return resultAction(.escape); + } + + // A digit is a count only before a command/prefix. A leading zero keeps + // its configured line-start role; after another digit it extends count. + if (state.prefix == .none and !key.ctrl and !key.alt and + key.cp >= '0' and key.cp <= '9' and + !(key.cp == '0' and state.count == 0)) + { + if (state.count < 0xffff) + state.count = state.count * 10 + key.cp - '0'; + return .pending; + } + + const counted = consumeCount(state); + const count = counted.count; + + switch (state.prefix) { + .goto => { + state.prefix = .none; + if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); + if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); + if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); + if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation }); + if (key.has(.goto_references)) return resultAction(.{ .lsp = .references }); + return .ignored; + }, + .view => { + state.prefix = .none; + if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_center)) return resultAction(.{ .view = .center }); + if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); + if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); + if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + return .ignored; + }, + .find_fwd, .find_back, .till_fwd, .till_back => |prefix| { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + const kind: Find = switch (prefix) { + .find_fwd => .forward, + .find_back => .backward, + .till_fwd => .till_forward, + .till_back => .till_backward, + else => unreachable, + }; + return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } }); + }, + .replace => { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + return resultAction(.{ .replace_char = char }); + }, + .match => { + if (state.match_sub == .none) { + if (key.has(.prefix_match)) { + state.prefix = .none; + return resultAction(.match_bracket); + } + const sub: MatchSub = if (key.has(.match_inside)) + .inside + else if (key.has(.match_around)) + .around + else if (key.has(.surround_add)) + .surround_add + else if (key.has(.surround_replace)) + .surround_replace + else if (key.has(.surround_delete)) + .surround_delete + else { + state.prefix = .none; + return .ignored; + }; + state.match_sub = sub; + return .pending; + } + const char = key.literal() orelse { + state.clear(); + return .ignored; + }; + if (state.match_sub == .surround_replace and state.held_char == 0) { + state.held_char = char; + return .pending; + } + const sub = state.match_sub; + const from = state.held_char; + state.clear(); + return switch (sub) { + .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }), + .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }), + .surround_add => resultAction(.{ .surround_add = char }), + .surround_delete => resultAction(.{ .surround_delete = char }), + .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }), + .none => unreachable, + }; + }, + .next, .prev => |prefix| { + state.prefix = .none; + const direction: Direction = if (prefix == .next) .forward else .backward; + if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } }); + if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); + if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); + if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + return .ignored; + }, + .none => {}, + } + + // Prefix setters retain the count for their continuation. + if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); + if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); + if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0); + if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0); + if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); + if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); + if (key.has(.prefix_match)) return armPrefix(state, .match, 0); + + if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); + if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); + if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } }); + if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } }); + if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } }); + if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } }); + if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } }); + if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } }); + if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } }); + if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } }); + if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count }); + if (key.has(.line_start)) return resultAction(.{ .line = .start }); + if (key.has(.line_end)) return resultAction(.{ .line = .end }); + if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws }); + if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + + if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } }); + if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } }); + if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } }); + if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } }); + if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } }); + if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } }); + + if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } }); + if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } }); + if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } }); + if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } }); + if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } }); + if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } }); + if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } }); + + if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } }); + if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } }); + if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } }); + if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } }); + if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } }); + if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } }); + if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } }); + if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); + if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); + if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + + if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); + if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); + if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } }); + if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } }); + if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } }); + if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } }); + if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } }); + if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } }); + if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); + if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); + if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); + if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); + if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); + if (key.has(.format)) return resultAction(.{ .lsp = .format }); + if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) }); + if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) }); + if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } }); + if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } }); + if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } }); + + if (key.has(.leader)) return resultAction(.leader); + if (key.has(.command_line)) return resultAction(.command_line); + if (key.has(.pipe_selection)) return resultAction(.{ .pipe_selection = .replace }); + if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); + if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); + if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); + if (key.has(.search)) return resultAction(.search); + if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); + if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); + return .unbound; + } + + fn input(cp: u21, roles: []const Role) Input { + return .{ .cp = cp, .roles = .initMany(roles) }; + } + + test "counts survive prefixes and identical parser actions can feed both adapters" { + var text: State = .{}; + var pdf: State = .{}; + const sequence = [_]Input{ + input('1', &.{}), + input('2', &.{}), + input('g', &.{ .prefix_goto, .goto_file_start }), + input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }), + }; + for (sequence[0 .. sequence.len - 1]) |key| { + try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key)); + } + const ta = parse(&text, sequence[sequence.len - 1]); + const pa = parse(&pdf, sequence[sequence.len - 1]); + try std.testing.expectEqualDeep(ta, pa); + try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{ + .target = .line_down, + .count = 12, + .explicit_count = true, + } } }, ta); + try std.testing.expectEqual(State{}, text); + try std.testing.expectEqual(State{}, pdf); + } + + test "invalid continuations are ignored and clear prefix plus count" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto}))); + try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{}))); + try std.testing.expectEqual(State{}, state); + try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down}))); + } + + test "literal arguments retain conflicting command characters" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after}))); + + try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next}))); + try std.testing.expectEqual(State{}, state); + } + + test "modified and special keys cannot satisfy literal continuations" { + var state: State = .{}; + _ = parse(&state, input('r', &.{.prefix_replace})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true })); + try std.testing.expectEqual(State{}, state); + _ = parse(&state, input('f', &.{.prefix_find_fwd})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 })); + try std.testing.expectEqual(State{}, state); + } + + test "replace accepts a Unicode literal" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{}))); + try std.testing.expectEqual(State{}, state); + } + + test "once versus per-selection is semantic action metadata" { + try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope()); + try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope()); + } +}; + +test { + _ = Normal; +} + +// Cursor columns are UTF-8 byte offsets at grapheme boundaries; line.len is the terminator. pub const Cursor = struct { row: usize = 0, @@ -19,8 +659,7 @@ pub const Cursor = struct { } }; -// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other -// non-ws, ws = space/tab/newline). +// Unicode word characters, punctuation, and whitespace. pub const Kind = enum { word, punct, ws }; fn codepointAt(text: []const u8, off: usize) u21 { @@ -59,23 +698,6 @@ fn kindOfCodepoint(cp: u21) Kind { }; } -pub fn kindOf(c: u8) Kind { - return kindOfCodepoint(c); -} - -// "long word" (W/B/E): only whitespace separates; punct is part of a word. -fn kindOfLong(cp: u21) Kind { - return if (isUnicodeWhitespace(cp)) .ws else .word; -} - -fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { - if (c.row >= lines.len) return .ws; - const line = lines[c.row]; - if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace - const cp = codepointAt(line, graphemeStart(line, c.col)); - return if (long) kindOfLong(cp) else kindOfCodepoint(cp); -} - fn lineLenOf(lines: []const []const u8, row: usize) usize { if (row >= lines.len) return 0; return lines[row].len; @@ -110,30 +732,12 @@ fn stepBwd(lines: []const []const u8, c: *Cursor) bool { return true; } -// at EOF? (past the last line's last char) -fn atEof(lines: []const []const u8, c: Cursor) bool { - if (c.row >= lines.len) return true; - if (c.row + 1 < lines.len) return false; - return c.col >= lines[c.row].len; -} - pub fn firstNonWs(line: []const u8) usize { var i: usize = 0; while (i < line.len and isUnicodeWhitespace(codepointAt(line, i))) i = nextGrapheme(line, i); return i; } -// ---- per-line motions ---- - -pub fn lineStart(c: Cursor) Cursor { - return .{ .row = c.row, .col = 0 }; -} - -pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { - const llen = lineLenOf(lines, c.row); - return .{ .row = c.row, .col = if (llen == 0) 0 else prevGrapheme(lines[c.row], llen) }; -} - pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { // the row can sit past the content (mouse click below a short pane's // last line) — out of range reads as an empty line, like lineLenOf @@ -141,8 +745,6 @@ pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; } -// ---- char/line motions ---- - pub fn charLeft(lines: []const []const u8, c: Cursor) Cursor { if (c.row >= lines.len) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = prevGrapheme(lines[c.row], @min(c.col, lines[c.row].len)) }; @@ -160,91 +762,6 @@ fn clampLineCol(line: []const u8, col: usize) usize { return graphemeStart(line, @min(col, last)); } -pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; - const llen = lineLenOf(lines, nr); - return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) }; -} - -pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { - const nr = if (c.row > 0) c.row - 1 else c.row; - const llen = lineLenOf(lines, nr); - return .{ .row = nr, .col = if (llen == 0) 0 else clampLineCol(lines[nr], c.col) }; -} - -// ---- word motions ---- - -// `w`/`W`: to the start of the next word. -pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - const start_kind = kindAt(lines, p, long); - if (start_kind != .ws) { - // skip the rest of the current word-class run - while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { - if (!stepFwd(lines, &p)) break; - } - } - // skip whitespace (incl. newlines) to the next word start - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - // p now sits on the next word's first char (or EOF -> last valid pos) - return clampToChar(lines, p); -} - -// `b`/`B`: to the start of the previous word. -pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepBwd(lines, &p)) return c; // at buffer start - // skip whitespace backward - while (kindAt(lines, p, long) == .ws) { - if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; - } - // now on the end of the previous word; walk back to its start - const k = kindAt(lines, p, long); - while (true) { - var q = p; - if (!stepBwd(lines, &q)) { - p.col = 0; - break; - } - if (kindAt(lines, q, long) != k) break; // crossed into prior class - p = q; - } - return clampToChar(lines, p); -} - -// `e`/`E`: to the end of the current/next word. -pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { - var p = c; - if (!stepFwd(lines, &p)) return clampToChar(lines, c); - // skip whitespace forward - while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { - if (!stepFwd(lines, &p)) break; - } - if (atEof(lines, p)) return clampToChar(lines, p); - // now on a word's first char; advance to the last char of this run - const k = kindAt(lines, p, long); - while (!atEof(lines, p)) { - var q = p; - if (!stepFwd(lines, &q)) break; - if (kindAt(lines, q, long) != k) break; - p = q; - } - return clampToChar(lines, p); -} - -// ---- goto ---- - -pub fn gotoFirst() Cursor { - return .{ .row = 0, .col = 0 }; -} - -pub fn gotoLast(lines: []const []const u8) Cursor { - const r = if (lines.len == 0) 0 else lines.len - 1; - return .{ .row = r, .col = 0 }; -} - // the character the cursor sits on; line terminators / EOF read as '\n'. fn codepointAtCursor(lines: []const []const u8, c: Cursor) u21 { if (c.row >= lines.len) return '\n'; @@ -258,26 +775,6 @@ fn charAt(lines: []const []const u8, c: Cursor) u8 { return if (cp <= 0x7f) @intCast(cp) else 0; } -// `f`/`F`/`t`/`T`: the nth occurrence of `ch` after/before the cursor, across -// line boundaries (helix: not confined to the line). `till` stops one position -// short of the hit. Returns null (no move) when there aren't n occurrences. -pub fn findChar(lines: []const []const u8, c: Cursor, ch: u21, fwd: bool, till: bool, n: usize) ?Cursor { - var p = clampToChar(lines, c); - var left = if (n == 0) 1 else n; - while (left > 0) { - if (fwd) { - if (!stepFwd(lines, &p)) return null; - } else { - if (!stepBwd(lines, &p)) return null; - } - if (codepointAtCursor(lines, p) == ch) left -= 1; - } - if (till) { - if (fwd) _ = stepBwd(lines, &p) else _ = stepFwd(lines, &p); - } - return clampToChar(lines, p); -} - // `mm`: the bracket matching the one under the cursor (dumb text scan with // nesting; no tree-sitter). Null when the cursor is not on a bracket. pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor { @@ -314,31 +811,6 @@ pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor { return null; } -fn isBlank(line: []const u8) bool { - return firstNonWs(line) == line.len; -} - -// `]p`: the start of the next blank-line-delimited block (or the last line). -pub fn paragraphFwd(lines: []const []const u8, c: Cursor) Cursor { - var r = c.row; - while (r < lines.len and !isBlank(lines[r])) r += 1; - while (r < lines.len and isBlank(lines[r])) r += 1; - if (r >= lines.len) return gotoLast(lines); - return .{ .row = r, .col = 0 }; -} - -// `[p`: the start of the current block, or of the previous one when already -// on a block start / a blank line. -pub fn paragraphBwd(lines: []const []const u8, c: Cursor) Cursor { - if (c.row == 0 or lines.len == 0) return .{ .row = 0, .col = 0 }; - var r = @min(c.row, lines.len) - 1; - while (r > 0 and isBlank(lines[r])) r -= 1; - while (r > 0 and !isBlank(lines[r - 1])) r -= 1; - return .{ .row = r, .col = 0 }; -} - -// ---- textobject / surround range math (mi/ma/ms/mr/md) ---- - // an inclusive char range [a, b] in document order pub const Range = struct { a: Cursor, b: Cursor }; @@ -380,94 +852,6 @@ pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range { return null; } -// mi/ma over a bracket pair: `around` keeps the brackets, inside shrinks them -// off (null when nothing is left between them). -pub fn pairRange(lines: []const []const u8, c: Cursor, open: u8, close: u8, around: bool) ?Range { - const r = enclosingPair(lines, c, open, close) orelse return null; - if (around) return r; - return shrinkOffDelims(lines, r); -} - -pub fn quoteRange(lines: []const []const u8, c: Cursor, q: u8, around: bool) ?Range { - const r = enclosingQuote(lines, c, q) orelse return null; - if (around) return r; - return shrinkOffDelims(lines, r); -} - -fn shrinkOffDelims(lines: []const []const u8, r: Range) ?Range { - var a = r.a; - var b = r.b; - if (!stepFwd(lines, &a)) return null; - if (!stepBwd(lines, &b)) return null; - if (b.row < a.row or (b.row == a.row and b.col < a.col)) return null; // empty inside - return .{ .a = a, .b = b }; -} - -// miw/maw (and W): the word run under the cursor; `around` adds the trailing -// whitespace on the line (or the leading run when there is none). -pub fn wordRange(lines: []const []const u8, c0: Cursor, long: bool, around: bool) ?Range { - const c = clampToChar(lines, c0); - if (c.row >= lines.len) return null; - const line = lines[c.row]; - if (line.len == 0 or c.col >= line.len) return null; - const k = kindAt(lines, c, long); - if (k == .ws) return null; - var lo = c.col; - while (lo > 0) { - const prev = prevGrapheme(line, lo); - if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != k) break; - lo = prev; - } - var hi = c.col; - while (true) { - const next = nextGrapheme(line, hi); - if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != k) break; - hi = next; - } - if (around) { - var h2 = hi; - while (true) { - const next = nextGrapheme(line, h2); - if (next >= line.len or kindAt(lines, .{ .row = c.row, .col = next }, long) != .ws) break; - h2 = next; - } - if (h2 != hi) { - hi = h2; - } else { - while (lo > 0) { - const prev = prevGrapheme(line, lo); - if (kindAt(lines, .{ .row = c.row, .col = prev }, long) != .ws) break; - lo = prev; - } - } - } - return .{ .a = .{ .row = c.row, .col = lo }, .b = .{ .row = c.row, .col = hi } }; -} - -// mip/map: the blank-line-delimited block around the cursor; `around` adds the -// trailing blank lines (or the leading ones when there are none). -pub fn paragraphRange(lines: []const []const u8, c0: Cursor, around: bool) ?Range { - const c = clampToChar(lines, c0); - if (c.row >= lines.len or isBlank(lines[c.row])) return null; - var r0 = c.row; - while (r0 > 0 and !isBlank(lines[r0 - 1])) r0 -= 1; - var r1 = c.row; - while (r1 + 1 < lines.len and !isBlank(lines[r1 + 1])) r1 += 1; - if (around) { - var r2 = r1; - while (r2 + 1 < lines.len and isBlank(lines[r2 + 1])) r2 += 1; - if (r2 != r1) { - r1 = r2; - } else { - while (r0 > 0 and isBlank(lines[r0 - 1])) r0 -= 1; - } - } - const llen = lineLenOf(lines, r1); - return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else prevGrapheme(lines[r1], llen) } }; -} - -// ---- helpers used by motions + main.zig ---- - // clamp a (possibly terminator/EOF) position onto a real character. pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { if (c.row >= lines.len) { @@ -505,21 +889,11 @@ pub fn lineSlice(content: []const u8, row: usize) []const u8 { return content[start..nl]; } -/// The byte span of line `row`, in ONE scan that stops at that row. -/// -/// This exists because the obvious spelling costs a scan of the WHOLE document per call and the -/// obvious USE of it costs several. `insertAt` below read `lineCount` twice merely to clamp a row, -/// and `lineCount` is `std.mem.count` over every byte; on a 19 MB fixture that was two full passes -/// before a single character could be inserted. Measured with `zig build perf`: `edit-char` on the -/// 300 000-line fixture cost 15.0 ms, against 1.5 ms to render the frame that shows it. -/// -/// Returns null when the row does not exist, so a caller that must clamp pays for the count only on -/// that path - which is the rare one, since a cursor is normally inside its document. +/// A bounded scan of one line; null if the row does not exist. pub const LineSpan = struct { start: usize, end: usize }; pub fn lineSpan(content: []const u8, row: usize) ?LineSpan { - // An empty document has no lines at all, which is what `lineCount` says about it - not one - // empty line. Agreeing with that here is what lets `insertAt` fall through to offset 0. + // Match lineCount: empty content has no lines; a trailing newline adds one. if (content.len == 0) return null; var start: usize = 0; var r: usize = 0; @@ -527,15 +901,12 @@ pub fn lineSpan(content: []const u8, row: usize) ?LineSpan { const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse return null; start = nl + 1; } - // Row `row` exists if it begins inside the content, OR it is the empty last line after a - // trailing newline - which `lineCount` also counts, so the two agree. if (start > content.len) return null; if (start == content.len and !(row == 0 or content.len == 0 or content[content.len - 1] == '\n')) return null; const end = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len; return .{ .start = start, .end = end }; } -// ---- file content mutations. caller frees the returned slice + the old one. ---- fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: usize, replacement: []const u8) ![]u8 { const out = try alloc.alloc(u8, content.len - (end - start) + replacement.len); @memcpy(out[0..start], content[0..start]); @@ -546,8 +917,7 @@ fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: /// insert `text` at (row, col). col is clamped to the line length. pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { - // One bounded scan on the common path. The fallback keeps the old clamping exactly - a row past - // the end lands on the last line - and only it pays for a full count. + // Only an out-of-range row requires counting the whole document. const span = lineSpan(content, c.row) orelse blk: { const last = lineCount(content) -| 1; break :blk lineSpan(content, last) orelse LineSpan{ .start = content.len, .end = content.len }; @@ -728,106 +1098,10 @@ pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C return out; } -// `J`: join line `row` with the next — the newline and the next line's leading -// whitespace become one space (helix join). `col` is the space's column. -// Null when `row` is the last line. -pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?struct { content: []u8, col: usize } { - if (row + 1 >= lineCount(content)) return null; - const a = lineSlice(content, row); - const next = lineSlice(content, row + 1); - const b = std.mem.trimStart(u8, next, " \t"); - const start = lineStartOffset(content, row); - const rest = lineStartOffset(content, row + 1) + (next.len - b.len); - const prefix_end = start + a.len; - const out = try alloc.alloc(u8, prefix_end + 1 + content.len - rest); - @memcpy(out[0..prefix_end], content[0..prefix_end]); - out[prefix_end] = ' '; - @memcpy(out[prefix_end + 1 ..], content[rest..]); - return .{ .content = out, .col = a.len }; -} - // `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no // per-language indent config; 4 spaces, one tab counts as one level out. pub const INDENT_W = 4; -pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 { - const lo = @min(r0, r1); - const hi = @max(r0, r1); - var out_len = content.len; - var it = std.mem.splitScalar(u8, content, '\n'); - var row: usize = 0; - while (it.next()) |line| : (row += 1) { - if (row < lo or row > hi) continue; - if (add) { - if (line.len != 0) out_len += INDENT_W; - } else { - var cut: usize = 0; - if (line.len > 0 and line[0] == '\t') { - cut = 1; - } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1; - out_len -= cut; - } - } - - const out = try alloc.alloc(u8, out_len); - it = std.mem.splitScalar(u8, content, '\n'); - row = 0; - var write: usize = 0; - while (it.next()) |line| : (row += 1) { - if (row > 0) { - out[write] = '\n'; - write += 1; - } - var selected = line; - if (row >= lo and row <= hi) { - if (add) { - if (line.len != 0) { - @memset(out[write..][0..INDENT_W], ' '); - write += INDENT_W; - } - } else if (line.len > 0 and line[0] == '\t') { - selected = line[1..]; - } else { - var cut: usize = 0; - while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1; - selected = line[cut..]; - } - } - @memcpy(out[write..][0..selected.len], selected); - write += selected.len; - } - return out; -} - -// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor -// (helix: under the cursor only, no forward scan). Null when the cursor is -// not on a number. The new cursor sits on the number's last digit. -pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, delta: i64) !?struct { content: []u8, cur: Cursor } { - const line = lineSlice(content, c.row); - if (c.col >= line.len) return null; - var s = c.col; - var e = c.col; - if (!std.ascii.isDigit(line[s])) { - // sitting on the '-' of a negative number counts - if (!(line[s] == '-' and s + 1 < line.len and std.ascii.isDigit(line[s + 1]))) return null; - e = s + 1; - } - while (s > 0 and std.ascii.isDigit(line[s - 1])) s -= 1; - if (s > 0 and line[s - 1] == '-') s -= 1; - while (e < line.len and std.ascii.isDigit(line[e])) e += 1; - const val = std.fmt.parseInt(i64, line[s..e], 10) catch return null; - const nv = val +| delta; - var buf: [24]u8 = undefined; - // "{d}" prints '+' for positive signed ints — format the magnitude unsigned - const numstr = if (nv < 0) - std.fmt.bufPrint(&buf, "-{d}", .{@abs(nv)}) catch return null - else - std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null; - const off = lineStartOffset(content, c.row); - const start = off + s; - return .{ .content = try spliceAlloc(alloc, content, start, off + e, numstr), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } }; -} - // delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the // line length (the newline); a kill crossing it passes b = (row+1, 0). pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { @@ -837,35 +1111,14 @@ pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C return spliceAlloc(alloc, content, s, e, ""); } -// ---- helix range engine (phase 5) ---- -// -// Gap-offset ranges over the FLAT buffer, ported faithfully from -// helix-core/src/movement.rs + selection.rs @ 278b24389 (the genizah -// checkout). Positions are UTF-8 gap offsets 0..=text.len. Stored columns -// remain byte offsets, but every range boundary is an extended-grapheme -// boundary. A range with -// head > anchor selects [anchor, head) with the block cursor ON head-1; -// head < anchor selects [head, anchor) with the cursor ON head. The -// differential suite (test/hxcases, `zig build hxdiff`) pins every behavior -// here key-for-key against a real helix. - -pub const HxRange = struct { anchor: usize, head: usize }; - -/// The first byte of the grapheme cluster containing `off`. -/// -/// The general answer needs UAX #29, which is why the slow path below iterates from the start of -/// `text` with the full break state machine - and that made this the single hottest function in a -/// keystroke: 21.5% of a profiled edit at the ESP32-P4's 40x12 geometry, because the render path -/// calls it once per visible row with a column offset, so the cost follows the cursor's distance -/// along its line. That is exactly the shape measured on the die, where inserting at column 320 of -/// a fixed line cost 7.8 ms more than inserting at column 0 of the same line. -/// -/// The fast path is sound rather than approximate. In UAX #29 every ASCII scalar is its own -/// grapheme cluster with ONE exception, GB3: CR is joined to a following LF. Every other rule that -/// could extend a cluster across `off` - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator - -/// is spelled with non-ASCII scalars. So if the byte at `off` and the byte before it are both -/// ASCII and are not that CR-LF pair, `off` already IS a cluster boundary and there is nothing to -/// search for. Text that is not all ASCII still takes the slow path, byte for byte as before. +// Ported from Helix movement.rs and selection.rs at 278b24389. +// Half-open selections use UTF-8 byte gaps at grapheme boundaries. +// A forward selection's cursor is the grapheme before head; a backward one's is at head. + +pub const Selection = struct { anchor: usize, head: usize }; + +/// The first byte of the grapheme containing off. Adjacent ASCII bytes are +/// boundaries except CR-LF (UAX #29 GB3); other cases need full segmentation. pub fn graphemeStart(text: []const u8, off: usize) usize { const bounded = @min(off, text.len); if (bounded == text.len) return text.len; @@ -884,15 +1137,7 @@ pub fn graphemeStart(text: []const u8, off: usize) usize { /// one extended grapheme forward, clamped at text.len pub fn nextGrapheme(text: []const u8, off: usize) usize { if (off >= text.len) return text.len; - // The editor's own offsets are already boundaries. Keep the overwhelmingly - // common ASCII path O(1); only repair a continuation-byte input here. - // - // GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a - // following LF into the same cluster. `graphemeStart` spells that exclusion - // out (:875) and this did not, so the two disagreed about a CRLF file by - // exactly one byte — a head stepped onto the offset between CR and LF and - // `graphemeStart` then repaired it back onto the CR. Excluded here for the - // same reason and in the same words; everything else ASCII is still O(1). + // Inputs are grapheme boundaries; repair continuation bytes. CR-LF stays one cluster. if (text[off] < 0x80 and (off + 1 == text.len or text[off + 1] < 0x80) and !(text[off] == '\r' and off + 1 < text.len and text[off + 1] == '\n')) return off + 1; var start = off; @@ -911,13 +1156,10 @@ pub fn prevGrapheme(text: []const u8, off: usize) usize { bounded = repaired; } if (bounded == 0) return 0; - // ...and the same GB3 exclusion, from the other side: a CR before this LF - // means the cluster starts one byte earlier than the fast path would say. + // GB3 also excludes stepping backward into CR-LF. if (text[bounded - 1] < 0x80 and (bounded == 1 or text[bounded - 2] < 0x80) and !(bounded >= 2 and text[bounded - 2] == '\r' and text[bounded - 1] == '\n')) return bounded - 1; - // Graphemes cannot cross a line break. Restrict the forward segmentation - // needed for a reverse step to the current line instead of rescanning the - // complete buffer. + // No grapheme crosses a line break; reverse segmentation only needs this line. const line_start = if (std.mem.lastIndexOfScalar(u8, text[0 .. bounded - 1], '\n')) |nl| nl + 1 else 0; if (line_start == bounded) return bounded - 1; // the newline is its own editor cell var it = uucode.grapheme.utf8Iterator(text[line_start..bounded]); @@ -935,14 +1177,12 @@ pub fn graphemeAtColumn(text: []const u8, column: usize) usize { } /// the block cursor cell of a range (helix Range::cursor) -pub fn hxCursor(text: []const u8, r: HxRange) usize { +pub fn selectionCursor(text: []const u8, r: Selection) usize { return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head; } -/// helix Range::put_cursor: park the block cursor at cell `idx`, optionally -/// extending — the anchor shifts one grapheme when the head crosses it so the -/// anchor CELL stays fixed. -pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRange { +/// Crossing the anchor moves its byte gap one grapheme, preserving the anchor cell. +pub fn moveSelectionCursor(text: []const u8, r: Selection, idx: usize, extend: bool) Selection { if (!extend) return .{ .anchor = idx, .head = idx }; var anchor = r.anchor; if (r.head >= r.anchor and idx < r.anchor) { @@ -956,39 +1196,35 @@ pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRan // ropey-style line math: len_lines = count('\n') + 1 — the slot after a // trailing '\n' is a real, empty last line and the cursor can sit there. -pub fn hxLineCount(text: []const u8) usize { +pub fn cursorLineCount(text: []const u8) usize { return std.mem.count(u8, text, "\n") + 1; } -pub fn hxLineOf(text: []const u8, off: usize) usize { +pub fn lineAtOffset(text: []const u8, off: usize) usize { return std.mem.count(u8, text[0..@min(off, text.len)], "\n"); } /// offset of line's terminator ('\n'), or text.len on the last line -pub fn hxLineEndIdx(text: []const u8, line: usize) usize { +pub fn lineEndOffset(text: []const u8, line: usize) usize { const s = lineStartOffset(text, line); return if (std.mem.indexOfScalarPos(u8, text, s, '\n')) |nl| nl else text.len; } /// gap offset -> (row, col) cell -pub fn hxPos(text: []const u8, off: usize) Cursor { +pub fn positionAt(text: []const u8, off: usize) Cursor { const bounded = @min(off, text.len); - // The line start is the byte after the last '\n' BEFORE off, which is the - // same number lineStartOffset(text, row) walks the whole prefix to reach — - // one backward scan of a single line instead of a second pass over - // everything above the cursor. On a multi-MB buffer that second pass was - // most of what a keystroke cost. + // Find the line start backward without a second scan of the document prefix. const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0; const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; const col = graphemeStart(text[s..e], @min(bounded - s, e - s)); - return .{ .row = hxLineOf(text, s + col), .col = col }; + return .{ .row = lineAtOffset(text, s + col), .col = col }; } /// (row, col) -> clamped gap offset; col == line length lands ON the '\n' -pub fn hxOff(text: []const u8, c: Cursor) usize { - const row = @min(c.row, hxLineCount(text) - 1); +pub fn offsetAt(text: []const u8, c: Cursor) usize { + const row = @min(c.row, cursorLineCount(text) - 1); const s = lineStartOffset(text, row); - // hxLineEndIdx(text, row) inlined: it starts by walking to `row` again, + // lineEndOffset(text, row) inlined: it starts by walking to `row` again, // and we are already standing there const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; const raw = @min(c.col, e - s); @@ -1008,9 +1244,9 @@ pub const WordTarget = enum { // helix categorize_char: Eol is its OWN category, distinct from Whitespace — // that distinction is load-bearing in reached_target. -const HxCat = enum { word, punct, ws, eol }; +const WordClass = enum { word, punct, ws, eol }; -fn hxCatAt(text: []const u8, off: usize) HxCat { +fn wordClassAt(text: []const u8, off: usize) WordClass { if (off >= text.len) return .eol; const cp = codepointAt(text, off); if (cp == '\n' or cp == '\r') return .eol; @@ -1021,25 +1257,25 @@ fn hxCatAt(text: []const u8, off: usize) HxCat { }; } -fn hxIsWs(c: HxCat) bool { // Rust char::is_whitespace (includes line endings) +fn isWordWhitespace(c: WordClass) bool { // Rust char::is_whitespace (includes line endings) return c == .ws or c == .eol; } -fn hxIsWordBoundary(a: HxCat, b: HxCat) bool { +fn isWordBoundary(a: WordClass, b: WordClass) bool { return a != b; } -fn hxIsLongBoundary(a: HxCat, b: HxCat) bool { +fn isLongWordBoundary(a: WordClass, b: WordClass) bool { if ((a == .word and b == .punct) or (a == .punct and b == .word)) return false; return a != b; } -fn hxReached(target: WordTarget, prev: HxCat, next: HxCat) bool { +fn reachedWordTarget(target: WordTarget, prev: WordClass, next: WordClass) bool { return switch (target) { - .next_word_start, .prev_word_end => hxIsWordBoundary(prev, next) and (next == .eol or !hxIsWs(next)), - .next_word_end, .prev_word_start => hxIsWordBoundary(prev, next) and (!hxIsWs(prev) or next == .eol), - .next_long_word_start, .prev_long_word_end => hxIsLongBoundary(prev, next) and (next == .eol or !hxIsWs(next)), - .next_long_word_end, .prev_long_word_start => hxIsLongBoundary(prev, next) and (!hxIsWs(prev) or next == .eol), + .next_word_start, .prev_word_end => isWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)), + .next_word_end, .prev_word_start => isWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol), + .next_long_word_start, .prev_long_word_end => isLongWordBoundary(prev, next) and (next == .eol or !isWordWhitespace(next)), + .next_long_word_end, .prev_long_word_start => isLongWordBoundary(prev, next) and (!isWordWhitespace(prev) or next == .eol), }; } @@ -1051,12 +1287,12 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. -pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarget) HxRange { +pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; // block-cursor prep: collapse to the 1-wide cell at the head, pointing // in the motion direction (the anchor of the input is irrelevant) - var r: HxRange = if (is_prev) + var r: Selection = if (is_prev) (if (r0.anchor < r0.head) .{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) } else @@ -1067,7 +1303,7 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge else .{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) }); for (0..@max(1, count)) |_| { - const next = hxRangeToTarget(text, target, r, is_prev); + const next = wordRangeToTarget(text, target, r, is_prev); if (next.anchor == r.anchor and next.head == r.head) break; r = next; } @@ -1076,20 +1312,20 @@ pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarge // port of CharHelpers::range_to_target — a char iterator walking away from // origin.head; when reversed, "next" reads the byte just behind the position. -fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_prev: bool) HxRange { +fn wordRangeToTarget(text: []const u8, target: WordTarget, origin: Selection, is_prev: bool) Selection { var anchor = origin.anchor; var head = origin.head; var it = origin.head; - var prev_cat: ?HxCat = if (is_prev) - (if (it < text.len) hxCatAt(text, it) else null) + var prev_cat: ?WordClass = if (is_prev) + (if (it < text.len) wordClassAt(text, it) else null) else - (if (it > 0) hxCatAt(text, prevGrapheme(text, it)) else null); + (if (it > 0) wordClassAt(text, prevGrapheme(text, it)) else null); // skip any initial newline characters while (true) { if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break; const cell = if (is_prev) prevGrapheme(text, it) else it; - const cat = hxCatAt(text, cell); + const cat = wordClassAt(text, cell); if (cat != .eol) break; it = if (is_prev) cell else nextGrapheme(text, cell); prev_cat = cat; @@ -1102,8 +1338,8 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre while (true) { if ((is_prev and it == 0) or (!is_prev and it >= text.len)) break; const cell = if (is_prev) prevGrapheme(text, it) else it; - const next_cat = hxCatAt(text, cell); - if (prev_cat == null or hxReached(target, prev_cat.?, next_cat)) { + const next_cat = wordClassAt(text, cell); + if (prev_cat == null or reachedWordTarget(target, prev_cat.?, next_cat)) { if (head == head_start) anchor = head else break; } prev_cat = next_cat; @@ -1114,34 +1350,34 @@ fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_pre } /// a ropey "line is a line ending" — the line has no content of its own -fn hxLineIsEmpty(text: []const u8, line: usize) bool { - return lineStartOffset(text, line) == hxLineEndIdx(text, line); +fn lineIsEmpty(text: []const u8, line: usize) bool { + return lineStartOffset(text, line) == lineEndOffset(text, line); } /// ]p / [p: helix move_next_paragraph / move_prev_paragraph -pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: bool) HxRange { - const nlines = hxLineCount(text); - const cursor = hxCursor(text, r); - var line = hxLineOf(text, cursor); +pub fn moveParagraph(text: []const u8, r: Selection, count: usize, fwd: bool, extend: bool) Selection { + const nlines = cursorLineCount(text); + const cursor = selectionCursor(text, r); + var line = lineAtOffset(text, cursor); if (fwd) { const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); const last_char = prevGrapheme(text, nxt_start) == cursor; - const curr_empty = hxLineIsEmpty(text, line); - const next_empty = hxLineIsEmpty(text, @min(nlines - 1, line + 1)); + const curr_empty = lineIsEmpty(text, line); + const next_empty = lineIsEmpty(text, @min(nlines - 1, line + 1)); const curr_empty_to_line = curr_empty and !next_empty; // skip the character after the paragraph boundary if (curr_empty_to_line and last_char) line += 1; var l = line; var last_line = l; for (0..@max(1, count)) |_| { - while (l < nlines and !hxLineIsEmpty(text, l)) l += 1; - while (l < nlines and hxLineIsEmpty(text, l)) l += 1; + while (l < nlines and !lineIsEmpty(text, l)) l += 1; + while (l < nlines and lineIsEmpty(text, l)) l += 1; if (l == last_line) break; last_line = l; } const head = if (l >= nlines) text.len else lineStartOffset(text, l); const anchor = if (extend) - hxPutCursor(text, r, head, true).anchor + moveSelectionCursor(text, r, head, true).anchor else if (curr_empty_to_line and last_char) r.head else @@ -1149,22 +1385,22 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: return .{ .anchor = anchor, .head = head }; } const first_char = lineStartOffset(text, line) == cursor; - const prev_empty = hxLineIsEmpty(text, line -| 1); - const curr_empty = hxLineIsEmpty(text, line); + const prev_empty = lineIsEmpty(text, line -| 1); + const curr_empty = lineIsEmpty(text, line); const prev_empty_to_line = prev_empty and !curr_empty; // skip the character before the paragraph boundary if (prev_empty_to_line and !first_char) line += 1; var l = line; var last_line = l; for (0..@max(1, count)) |_| { - while (l > 0 and hxLineIsEmpty(text, l - 1)) l -= 1; - while (l > 0 and !hxLineIsEmpty(text, l - 1)) l -= 1; + while (l > 0 and lineIsEmpty(text, l - 1)) l -= 1; + while (l > 0 and !lineIsEmpty(text, l - 1)) l -= 1; if (l == last_line) break; last_line = l; } const head = lineStartOffset(text, l); const anchor = if (extend) - hxPutCursor(text, r, head, true).anchor + moveSelectionCursor(text, r, head, true).anchor else if (prev_empty_to_line and first_char) cursor else @@ -1174,19 +1410,19 @@ pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: /// j/k target: helix move_vertically — goal_col clamps to the line's content /// length, i.e. the cursor may land ON the '\n' of a shorter line. -pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize { - const nlines = hxLineCount(text); - const line = hxLineOf(text, pos); +pub fn verticalTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize { + const nlines = cursorLineCount(text); + const line = lineAtOffset(text, pos); const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count); const s = lineStartOffset(text, nline); - // hxLineEndIdx(text, nline) without its second walk to nline (see hxOff) + // lineEndOffset(text, nline) without its second walk to nline (see offsetAt) const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len; return s + graphemeStart(text[s..e], @min(goal_col, e - s)); } /// f/F/t/T target cell. helix find_char: the exclusive (till) search starts /// one further out so repeats make progress; not-found = null (no move). -pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize { +pub fn findTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: bool, count: usize) ?usize { var left = @max(1, count); if (fwd) { const head = nextGrapheme(text, cursor); @@ -1212,17 +1448,17 @@ pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u21, fwd: bool, till: b } // helix textobject.rs find_word_boundary -fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize { - var prev: HxCat = if (fwd) - (if (pos0 == 0) .ws else hxCatAt(text, prevGrapheme(text, pos0))) +fn findWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize { + var prev: WordClass = if (fwd) + (if (pos0 == 0) .ws else wordClassAt(text, prevGrapheme(text, pos0))) else - (if (pos0 >= text.len) .ws else hxCatAt(text, pos0)); + (if (pos0 >= text.len) .ws else wordClassAt(text, pos0)); var pos = pos0; var it = pos0; while (true) { if ((fwd and it >= text.len) or (!fwd and it == 0)) break; const cell = if (fwd) it else prevGrapheme(text, it); - const cat = hxCatAt(text, cell); + const cat = wordClassAt(text, cell); if (cat == .eol or cat == .ws) return pos; if (!long and cat != prev and pos != 0 and pos != text.len) return pos; it = if (fwd) nextGrapheme(text, cell) else cell; @@ -1234,34 +1470,34 @@ fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usiz /// miw/maw (and W): helix textobject_word — on whitespace it selects the /// whitespace run's boundary (a 1-wide cursor there) -pub fn hxTextobjectWord(text: []const u8, r: HxRange, around: bool, long: bool) HxRange { - const pos = hxCursor(text, r); - const word_start = hxFindWordBoundary(text, pos, false, long); - const cat: HxCat = if (pos < text.len) hxCatAt(text, pos) else .ws; - const word_end = if (cat == .ws or cat == .eol) pos else hxFindWordBoundary(text, nextGrapheme(text, pos), true, long); +pub fn selectWord(text: []const u8, r: Selection, around: bool, long: bool) Selection { + const pos = selectionCursor(text, r); + const word_start = findWordBoundary(text, pos, false, long); + const cat: WordClass = if (pos < text.len) wordClassAt(text, pos) else .ws; + const word_end = if (cat == .ws or cat == .eol) pos else findWordBoundary(text, nextGrapheme(text, pos), true, long); if (word_start == word_end or !around) return .{ .anchor = word_start, .head = word_end }; var end = word_end; - while (end < text.len and hxIsWs(hxCatAt(text, end)) and hxCatAt(text, end) != .eol) + while (end < text.len and isWordWhitespace(wordClassAt(text, end)) and wordClassAt(text, end) != .eol) end = nextGrapheme(text, end); if (end > word_end) return .{ .anchor = word_start, .head = end }; var start = word_start; while (start > 0) { const before = prevGrapheme(text, start); - const before_cat = hxCatAt(text, before); - if (!hxIsWs(before_cat) or before_cat == .eol) break; + const before_cat = wordClassAt(text, before); + if (!isWordWhitespace(before_cat) or before_cat == .eol) break; start = before; } return .{ .anchor = start, .head = word_end }; } /// mip/map: helix textobject_paragraph -pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: usize) HxRange { - const nlines = hxLineCount(text); - const cursor = hxCursor(text, r); - var line = hxLineOf(text, cursor); - const prev_empty = hxLineIsEmpty(text, line -| 1); - const curr_empty = hxLineIsEmpty(text, line); - const next_empty = line + 1 >= nlines or hxLineIsEmpty(text, line + 1); +pub fn selectParagraph(text: []const u8, r: Selection, around: bool, count: usize) Selection { + const nlines = cursorLineCount(text); + const cursor = selectionCursor(text, r); + var line = lineAtOffset(text, cursor); + const prev_empty = lineIsEmpty(text, line -| 1); + const curr_empty = lineIsEmpty(text, line); + const next_empty = line + 1 >= nlines or lineIsEmpty(text, line + 1); const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); const last_char = prevGrapheme(text, nxt_start) == cursor; const prev_empty_to_line = prev_empty and !curr_empty; @@ -1271,29 +1507,29 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: if (prev_empty_to_line or curr_empty_to_line) line_back += 1; // do not include the current paragraph on a paragraph end (include next) if (!(curr_empty_to_line and last_char)) { - while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; - while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1; } if (curr_empty_to_line and last_char) line += 1; const n = @max(1, count); var count_done: usize = 0; for (0..n) |_| { var done = false; - while (line < nlines and !hxLineIsEmpty(text, line)) { + while (line < nlines and !lineIsEmpty(text, line)) { line += 1; done = true; } - while (line < nlines and hxLineIsEmpty(text, line)) line += 1; + while (line < nlines and lineIsEmpty(text, line)) line += 1; if (done) count_done += 1; } // search one paragraph backwards when we ran off the end if (count_done != n and line >= nlines) { - while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; - while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and lineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !lineIsEmpty(text, line_back - 1)) line_back -= 1; } if (!around) { // inside: drop the trailing whitespace paragraph - while (line > 0 and hxLineIsEmpty(text, line - 1)) line -= 1; + while (line > 0 and lineIsEmpty(text, line - 1)) line -= 1; } return .{ .anchor = lineStartOffset(text, line_back), @@ -1301,25 +1537,25 @@ pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: }; } -test "hx textobject word and paragraph" { +test "selection textobjects distinguish words and paragraph boundaries" { const t = "alpha beta gamma\n"; // miw mid-word - var r = hxTextobjectWord(t, .{ .anchor = 8, .head = 9 }, false, false); + var r = selectWord(t, .{ .anchor = 8, .head = 9 }, false, false); try std.testing.expectEqual(@as(usize, 6), r.anchor); try std.testing.expectEqual(@as(usize, 10), r.head); // maw on the space after "beta": collapses to the boundary - r = hxTextobjectWord(t, .{ .anchor = 10, .head = 11 }, true, false); + r = selectWord(t, .{ .anchor = 10, .head = 11 }, true, false); try std.testing.expectEqual(@as(usize, 10), r.anchor); try std.testing.expectEqual(@as(usize, 10), r.head); const t2 = "aa\n\ncc\n"; // mip from the blank line selects the NEXT paragraph - r = hxTextobjectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1); + r = selectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1); try std.testing.expectEqual(@as(usize, 4), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); } /// leading-whitespace visual width (tab -> next multiple of INDENT_W) -pub fn hxIndentWidth(line: []const u8) usize { +pub fn indentWidth(line: []const u8) usize { var w: usize = 0; for (line) |ch| { if (ch == ' ') w += 1 else if (ch == '\t') w = (w / INDENT_W + 1) * INDENT_W else break; @@ -1329,37 +1565,32 @@ pub fn hxIndentWidth(line: []const u8) usize { /// full indent LEVELS of a line as spaces (helix indent_level_for_line: /// partial levels round down) — what o/O/insert-newline copy. -pub fn hxIndentString(line: []const u8) []const u8 { - const level = hxIndentWidth(line) / INDENT_W; +pub fn indentText(line: []const u8) []const u8 { + const level = indentWidth(line) / INDENT_W; const max = " "; // 8 levels is plenty (ponytail) return max[0..@min(level * INDENT_W, max.len)]; } -/// Indent width for an inserted newline. Keep the current full indent levels, -/// then add one logical tab after a simple delimiter-shaped line ending. -/// `)` intentionally includes both ordinary calls and the requested `})` -/// continuation shape; this is syntax-agnostic and does not try to parse. -pub fn hxNewlineIndentWidth(line: []const u8, col: usize) usize { +/// Copy full indent levels and add one after (, [, {, or ), without parsing. +pub fn newlineIndentWidth(line: []const u8, col: usize) usize { const prefix = std.mem.trimEnd(u8, line[0..@min(col, line.len)], " \t"); const extra = if (prefix.len == 0) false else switch (prefix[prefix.len - 1]) { '(', '[', '{', ')' => true, else => false, }; - return hxIndentString(line).len + @as(usize, if (extra) INDENT_W else 0); + return indentText(line).len + @as(usize, if (extra) INDENT_W else 0); } test "newline indent keeps levels and adds one after delimiters" { - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth(" value", 9)); - try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" call()", 10)); - try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" callback({}) ", 16)); - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("work(", 5)); - try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("list[tail", 5)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth(" value", 9)); + try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" call()", 10)); + try std.testing.expectEqual(@as(usize, 8), newlineIndentWidth(" callback({}) ", 16)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("work(", 5)); + try std.testing.expectEqual(@as(usize, 4), newlineIndentWidth("list[tail", 5)); } -/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer. -/// Zero-padding is preserved (width follows sign flips, helix-style). -/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only. -pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 { +/// Increment a selected decimal integer, preserving zero-padding across sign changes. +pub fn incrementDecimal(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 { if (frag.len == 0) return null; const neg = frag[0] == '-'; const digits = if (neg) frag[1..] else frag; @@ -1391,80 +1622,80 @@ pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[] return out; } -test "hx word moves match helix" { +test "word selections match Helix motions" { const t = "alpha beta\n"; // w from a fresh 1-wide cursor selects "alpha " (cursor on the space) - var r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); + var r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 6), r.head); // e from the same start ends on 'a' of alpha - r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end); + r = moveWord(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end); try std.testing.expectEqual(@as(usize, 5), r.head); try std.testing.expectEqual(@as(usize, 0), r.anchor); // b from the w result selects "alpha" backward - r = hxWordMove(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start); + r = moveWord(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start); try std.testing.expectEqual(@as(usize, 10), r.anchor); try std.testing.expectEqual(@as(usize, 6), r.head); // 2w on "one two three": anchor comes from the last hop only const t2 = "one two three\n"; - r = hxWordMove(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); + r = moveWord(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); try std.testing.expectEqual(@as(usize, 4), r.anchor); try std.testing.expectEqual(@as(usize, 8), r.head); // w at EOF collapses to a zero-width range at len const t3 = "alpha\n"; - r = hxWordMove(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start); + r = moveWord(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 6), r.head); try std.testing.expectEqual(@as(usize, 6), r.anchor); // W treats punct runs as word chars const t4 = "foo.bar baz\n"; - r = hxWordMove(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); + r = moveWord(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 8), r.head); } -test "hx put cursor keeps the anchor cell across crossings" { +test "selection cursor keeps the anchor cell across crossings" { const t = "abcdef\n"; // forward range [2,3) extended left of the anchor: anchor cell stays 2 - var r = hxPutCursor(t, .{ .anchor = 2, .head = 3 }, 0, true); + var r = moveSelectionCursor(t, .{ .anchor = 2, .head = 3 }, 0, true); try std.testing.expectEqual(@as(usize, 3), r.anchor); try std.testing.expectEqual(@as(usize, 0), r.head); - try std.testing.expectEqual(@as(usize, 0), hxCursor(t, r)); + try std.testing.expectEqual(@as(usize, 0), selectionCursor(t, r)); // and back: cursor to 4 -> forward again, anchor gap back to 2 - r = hxPutCursor(t, r, 4, true); + r = moveSelectionCursor(t, r, 4, true); try std.testing.expectEqual(@as(usize, 2), r.anchor); try std.testing.expectEqual(@as(usize, 5), r.head); } -test "hx paragraph moves" { +test "paragraph selections cross blank lines" { const t = "aa\nbb\n\ncc\ndd\n\nee\n"; // ]p from the top selects through the blank line to the next block - var r = hxParaMove(t, .{ .anchor = 0, .head = 1 }, 1, true, false); + var r = moveParagraph(t, .{ .anchor = 0, .head = 1 }, 1, true, false); try std.testing.expectEqual(@as(usize, 0), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); // [p from "ee" (line 6, offset 14) goes back to "cc" block start - r = hxParaMove(t, .{ .anchor = 14, .head = 15 }, 1, false, false); + r = moveParagraph(t, .{ .anchor = 14, .head = 15 }, 1, false, false); try std.testing.expectEqual(@as(usize, 14), r.anchor); try std.testing.expectEqual(@as(usize, 7), r.head); } -test "hx vertical: goal col clamps onto the newline cell" { +test "vertical target clamps the goal column onto the newline cell" { const t = "abcdef\nab\nabcdef\n"; // from (0,5) down: line "ab" clamps to its '\n' at offset 9 - try std.testing.expectEqual(@as(usize, 9), hxVertTarget(t, 5, true, 1, 5)); + try std.testing.expectEqual(@as(usize, 9), verticalTarget(t, 5, true, 1, 5)); // two down with the same goal restores col 5 - try std.testing.expectEqual(@as(usize, 15), hxVertTarget(t, 9, true, 1, 5)); + try std.testing.expectEqual(@as(usize, 15), verticalTarget(t, 9, true, 1, 5)); } -test "hx find targets" { +test "find targets count matches and skip adjacent till targets" { const t = "abcabc\n"; - try std.testing.expectEqual(@as(usize, 2), hxFindTarget(t, 0, 'c', true, false, 1).?); - try std.testing.expectEqual(@as(usize, 5), hxFindTarget(t, 0, 'c', true, false, 2).?); - try std.testing.expectEqual(@as(usize, 1), hxFindTarget(t, 0, 'c', true, true, 1).?); + try std.testing.expectEqual(@as(usize, 2), findTarget(t, 0, 'c', true, false, 1).?); + try std.testing.expectEqual(@as(usize, 5), findTarget(t, 0, 'c', true, false, 2).?); + try std.testing.expectEqual(@as(usize, 1), findTarget(t, 0, 'c', true, true, 1).?); // till repeat skips the adjacent target: from cell 1, next tc reaches 4 - try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 1, 'c', true, true, 1).?); - try std.testing.expectEqual(@as(usize, 3), hxFindTarget(t, 5, 'a', false, false, 1).?); - try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 5, 'a', false, true, 1).?); - try std.testing.expectEqual(@as(?usize, null), hxFindTarget(t, 0, 'z', true, false, 1)); + try std.testing.expectEqual(@as(usize, 4), findTarget(t, 1, 'c', true, true, 1).?); + try std.testing.expectEqual(@as(usize, 3), findTarget(t, 5, 'a', false, false, 1).?); + try std.testing.expectEqual(@as(usize, 4), findTarget(t, 5, 'a', false, true, 1).?); + try std.testing.expectEqual(@as(?usize, null), findTarget(t, 0, 'z', true, false, 1)); } test "extended grapheme boundaries cover combining emoji flag and CJK text" { @@ -1482,10 +1713,7 @@ test "extended grapheme boundaries cover combining emoji flag and CJK text" { } test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 walk" { - // Both functions answer ASCII from arithmetic and hand everything else to the segmenter. The - // guard is a claim about UAX #29 (an ASCII scalar is its own cluster unless the next scalar - // extends it, and every extender is non-ASCII), so pin it against the walk it skips rather - // than against transcribed offsets: same text, both routes, every offset including past the end. + // Compare every offset against segmentation with the ASCII fast paths removed. const H = struct { // `graphemeStart` with the ASCII arm deleted — nothing else changed. fn start(text: []const u8, off: usize) usize { @@ -1522,19 +1750,14 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa } }; - // Scalars that extend a preceding ASCII base into ONE cluster, which is the whole reason the - // fast path inspects its neighbour: a combining mark, a ZWJ sequence, a spacing mark - // (Devanagari visarga), a variation selector. Plus wide glyphs, a regional-indicator pair, - // and three shapes of invalid UTF-8 the segmenter must still be trusted with: a bad start - // byte, a truncated tail, a bad continuation. + // Combining marks, ZWJ, spacing marks, selectors, wide glyphs, flags, and invalid UTF-8. const neighbours = [_][]const u8{ "", "a", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}\u{754c}", "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", }; - // Every byte the range test can see, ASCII and not: 0x20..0x7e take the fast path, and \t, \r, - // the rest of the C0 controls and DEL are excluded by it and must still reach the same answer. + // Every ASCII byte paired with each Unicode or invalid neighbor, in both orders. var buf: [16]u8 = undefined; var b: u8 = 0; while (b < 0x80) : (b += 1) { @@ -1549,26 +1772,13 @@ test "the ASCII arms of graphemeStart and nextGrapheme agree with the UAX #29 wa } } - // Text that has no CR-LF pair in it: GB3 is the one ASCII-only rule that joins two clusters, - // and it gets its own test below because it is the single exclusion every fast path has to - // carry by hand. for ([_][]const u8{ "a\r", "\ra", "\n\r", "a\rb\nc" }) |text| try H.check(text); // Mixed text long enough that a fast-path run starts, ends and restarts inside one string. try H.check("plain ascii then \u{4e16}\u{754c} then e\u{301} then more ascii"); } -// GB3 is the one UAX #29 rule that joins two ASCII scalars: CR takes a following LF into the same -// cluster. Each of the three steppers carries that exclusion separately - `graphemeStart` at :875, -// `nextGrapheme`'s ASCII arm at :896, `prevGrapheme`'s at :916 - so nothing but a test keeps them -// agreeing. The invariant is that all three answer the same CRLF boundary: for every cluster the -// segmenter reports, `graphemeStart` maps its start to itself, `nextGrapheme` maps that start to -// its end, and `prevGrapheme` maps its end back to the start. -// -// This was a live bug: `nextGrapheme` and `prevGrapheme` stepped exactly one byte whenever the -// byte at the offset and its neighbour were ASCII, so on a CRLF file the flat-buffer range engine -// could step a head to offset 1 and `graphemeStart` would repair that same offset back to 0. Both -// arms now spell the exclusion out, and this test is what holds them there. +// CR-LF must have identical boundaries in forward, backward, and containing-cluster queries. test "GB3 keeps CR-LF one cluster for every grapheme step" { const text = "a\r\nb"; // The reference: the same segmentation the slow arms of these functions run. @@ -1588,112 +1798,69 @@ test "GB3 keeps CR-LF one cluster for every grapheme step" { } test "Unicode find and word motion stay on grapheme boundaries" { - const lines = [_][]const u8{"\u{e9}x\u{e9}"}; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(&lines, .{ .row = 0, .col = 0 }, 'é', true, false, 1).?); + try std.testing.expectEqual(@as(usize, 3), findTarget("\u{e9}x\u{e9}", 0, 'é', true, false, 1).?); const text = "café 世界 ok\n"; - const first = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); + const first = moveWord(text, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); try std.testing.expectEqual(@as(usize, 0), first.anchor); try std.testing.expectEqual(@as(usize, 6), first.head); - const second = hxWordMove(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); + const second = moveWord(text, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); try std.testing.expectEqual(@as(usize, 6), second.anchor); try std.testing.expectEqual(@as(usize, 13), second.head); // Long-word motions split on Unicode whitespace, not only ASCII spaces. const nbsp = "alpha\u{a0}beta\n"; - const long = hxWordMove(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); + const long = moveWord(nbsp, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); try std.testing.expectEqual(@as(usize, 7), long.head); } -test "hx increment" { +test "decimal increment preserves padding and handles sign changes" { const a = std.testing.allocator; { - const r = (try hxIncrement(a, "15", 1)).?; + const r = (try incrementDecimal(a, "15", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("16", r); } { - const r = (try hxIncrement(a, "007", 1)).?; + const r = (try incrementDecimal(a, "007", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("008", r); } { - const r = (try hxIncrement(a, "-3", 1)).?; + const r = (try incrementDecimal(a, "-3", 1)).?; defer a.free(r); try std.testing.expectEqualStrings("-2", r); } { - const r = (try hxIncrement(a, "9", -10)).?; + const r = (try incrementDecimal(a, "9", -10)).?; defer a.free(r); try std.testing.expectEqualStrings("-1", r); } - try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "a 1", 1)); - try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1)); + try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "a 1", 1)); + try std.testing.expectEqual(@as(?[]u8, null), try incrementDecimal(a, "", 1)); } -// ---- tests ---- - -test "kindOf" { - try std.testing.expectEqual(Kind.word, kindOf('a')); - try std.testing.expectEqual(Kind.word, kindOf('_')); - try std.testing.expectEqual(Kind.word, kindOf('9')); - try std.testing.expectEqual(Kind.punct, kindOf('.')); - try std.testing.expectEqual(Kind.punct, kindOf('(')); - try std.testing.expectEqual(Kind.ws, kindOf(' ')); - try std.testing.expectEqual(Kind.ws, kindOf('\n')); +test "codepoint classes distinguish words punctuation and whitespace" { + try std.testing.expectEqual(Kind.word, kindOfCodepoint('a')); + try std.testing.expectEqual(Kind.word, kindOfCodepoint('_')); + try std.testing.expectEqual(Kind.word, kindOfCodepoint('9')); + try std.testing.expectEqual(Kind.punct, kindOfCodepoint('.')); + try std.testing.expectEqual(Kind.punct, kindOfCodepoint('(')); + try std.testing.expectEqual(Kind.ws, kindOfCodepoint(' ')); + try std.testing.expectEqual(Kind.ws, kindOfCodepoint('\n')); } -test "char/line motions" { +test "insert cursor steps characters and skips indentation" { const lines = [_][]const u8{ "alpha beta", " two words", "x" }; const c = Cursor{ .row = 0, .col = 5 }; try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(&.{"hello"}, c)); try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); - // line ends - try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); - // first non-ws try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); // cursor row past the content (mouse click below a short pane): no panic try std.testing.expectEqual(Cursor{ .row = 24, .col = 0 }, firstNonWsOf(&lines, Cursor{ .row = 24, .col = 3 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&lines, Cursor{ .row = 24, .col = 0 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&[_][]const u8{}, Cursor{ .row = 5, .col = 0 })); -} - -test "word motions w/b/e" { - const lines = [_][]const u8{"this is a test"}; - const w = &lines; - // "this is a test", indices 0..13 - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws - // b - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" - // e - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] - try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] -} - -test "word motions cross line" { - const lines = [_][]const u8{ "foo bar", "", "baz" }; - const w = &lines; - // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); - // b from "baz" crosses back to "bar" - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); - // e from row0 col0 -> "foo" end (col2) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); } test "lineSpan agrees with the whole-document scans it replaces" { - // The bounded scan is only worth having if it is indistinguishable from the pair it replaced, - // including at the edges that make line counting awkward: an empty document, a trailing - // newline (which is its own empty last line), and a row past the end. for ([_][]const u8{ "", "a", "a\n", "a\nbb\n", "a\nbb\nccc", "\n", "\n\n" }) |content| { const n = lineCount(content); var row: usize = 0; @@ -1717,27 +1884,11 @@ test "insertAt still clamps a row past the end onto the last line" { defer gpa.free(out); try std.testing.expectEqualStrings("a\nbb\ncccX", out); - // And an in-range insert lands where the old spelling put it. const mid = try insertAt(gpa, content, .{ .row = 1, .col = 1 }, "X"); defer gpa.free(mid); try std.testing.expectEqualStrings("a\nbXb\nccc", mid); } -test "long word W treats punct as word" { - // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) - const lines = [_][]const u8{"foo.bar baz"}; - const w = &lines; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); - // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); -} - -test "goto" { - const lines = [_][]const u8{ "a", "b", "c" }; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); -} - test "lineStartOffset + lineSlice" { const content = "alpha\nbeta\n\ngamma"; try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); @@ -1858,23 +2009,6 @@ test "clearLine" { try std.testing.expectEqualStrings("keep\n\nkeep2", r); } -test "findChar f/F/t/T across lines and counts" { - const lines = [_][]const u8{ "abcabc", "xa" }; - const w = &lines; - // f: next occurrence, on it - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 1).?); - // count: 2fa crosses into the next line - try std.testing.expectEqual(Cursor{ .row = 1, .col = 1 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 2).?); - // t stops one short - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, true, 1).?); - // F backward, on it - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, false, 1).?); - // T backward stops one after - try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, true, 1).?); - // not found: null, no move - try std.testing.expectEqual(@as(?Cursor, null), findChar(w, .{ .row = 0, .col = 0 }, 'z', true, false, 1)); -} - test "matchBracket nesting both directions" { const lines = [_][]const u8{"a (b (c) d) e"}; const w = &lines; @@ -1891,78 +2025,30 @@ test "matchBracket across lines" { try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?); } -test "paragraph motions" { - const lines = [_][]const u8{ "one", "two", "", "", "three", "four", "", "five" }; - const w = &lines; - try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphFwd(w, .{ .row = 0, .col = 1 })); - try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 4, .col = 0 })); - // no next block: the last line - try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 7, .col = 0 })); - // from mid-block up to its start; from a start up to the previous block's - try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphBwd(w, .{ .row = 5, .col = 1 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 4, .col = 0 })); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 0, .col = 0 })); -} - -test "pairRange inside/around, cursor on and between brackets" { +test "enclosing pair includes delimiters and chooses the nearest nested pair" { const lines = [_][]const u8{"f(a, (b))"}; const w = &lines; - const around = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', true).?; + const around = enclosingPair(w, .{ .row = 0, .col = 3 }, '(', ')').?; try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, around.a); try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, around.b); - const inside = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, inside.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, inside.b); - // cursor on the nested open picks the nested pair - const nested = pairRange(w, .{ .row = 0, .col = 5 }, '(', ')', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.b); - // empty pair: no inside + const nested = enclosingPair(w, .{ .row = 0, .col = 5 }, '(', ')').?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nested.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, nested.b); const empty = [_][]const u8{"()"}; - try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 0 }, '(', ')', false)); - // not enclosed - try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 1 }, '[', ']', false)); + const pair = enclosingPair(&empty, .{ .row = 0, .col = 0 }, '(', ')').?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, pair.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, pair.b); + try std.testing.expectEqual(@as(?Range, null), enclosingPair(&empty, .{ .row = 0, .col = 1 }, '[', ']')); } -test "quoteRange line-scoped" { +test "enclosing quote stays on its line" { const lines = [_][]const u8{"say 'hi there' end"}; const w = &lines; - const r = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', true).?; + const r = enclosingQuote(w, .{ .row = 0, .col = 7 }, '\'').?; try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, r.a); try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, r.b); - const ri = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, ri.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 12 }, ri.b); - // cursor after the pair: not enclosed - try std.testing.expectEqual(@as(?Range, null), quoteRange(w, .{ .row = 0, .col = 16 }, '\'', true)); -} - -test "wordRange inside/around" { - const lines = [_][]const u8{"one two.three"}; - const w = &lines; - const r = wordRange(w, .{ .row = 0, .col = 1 }, false, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, r.b); - // around eats the trailing spaces - const ra = wordRange(w, .{ .row = 0, .col = 1 }, false, true).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, ra.b); - // long word spans the dot - const rl = wordRange(w, .{ .row = 0, .col = 6 }, true, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, rl.a); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, rl.b); - // on whitespace: none - try std.testing.expectEqual(@as(?Range, null), wordRange(w, .{ .row = 0, .col = 3 }, false, false)); -} - -test "paragraphRange inside/around" { - const lines = [_][]const u8{ "a", "b", "", "c" }; - const w = &lines; - const r = paragraphRange(w, .{ .row = 1, .col = 0 }, false).?; - try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); - try std.testing.expectEqual(Cursor{ .row = 1, .col = 0 }, r.b); - const ra = paragraphRange(w, .{ .row = 1, .col = 0 }, true).?; - try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, ra.b); - try std.testing.expectEqual(@as(?Range, null), paragraphRange(w, .{ .row = 2, .col = 0 }, false)); + try std.testing.expectEqual(@as(?Range, null), enclosingQuote(w, .{ .row = 0, .col = 16 }, '\'')); + try std.testing.expectEqual(@as(?Range, null), enclosingQuote(&.{ "'open", "close'" }, .{}, '\'')); } test "advanceBy" { @@ -1997,46 +2083,6 @@ test "changeCase" { try std.testing.expectEqualStrings("ab CD", up); } -test "joinLine" { - const a = std.testing.allocator; - const r = (try joinLine(a, "one\n two\nthree", 0)).?; - defer a.free(r.content); - try std.testing.expectEqualStrings("one two\nthree", r.content); - try std.testing.expectEqual(@as(usize, 3), r.col); - // last line: nothing to join - try std.testing.expectEqual(@as(?@TypeOf(r), null), try joinLine(a, "one", 0)); -} - -test "indentLines add and remove" { - const a = std.testing.allocator; - const r = try indentLines(a, "one\n\ntwo", 0, 2, true); - defer a.free(r); - try std.testing.expectEqualStrings(" one\n\n two", r); - const u = try indentLines(a, " one\n\ttwo\n three\nx", 0, 2, false); - defer a.free(u); - try std.testing.expectEqualStrings("one\ntwo\nthree\nx", u); -} - -test "adjustNumber" { - const a = std.testing.allocator; - const r = (try adjustNumber(a, "x 41 y", .{ .row = 0, .col = 3 }, 1)).?; - defer a.free(r.content); - try std.testing.expectEqualStrings("x 42 y", r.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, r.cur); - // negative, cursor on the '-' - const n = (try adjustNumber(a, "v=-1;", .{ .row = 0, .col = 2 }, -1)).?; - defer a.free(n.content); - try std.testing.expectEqualStrings("v=-2;", n.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, n.cur); - // width change moves the last-digit column - const g = (try adjustNumber(a, "9", .{ .row = 0, .col = 0 }, 1)).?; - defer a.free(g.content); - try std.testing.expectEqualStrings("10", g.content); - try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, g.cur); - // not on a number - try std.testing.expectEqual(@as(?@TypeOf(r), null), try adjustNumber(a, "abc", .{ .row = 0, .col = 0 }, 1)); -} - test "deleteSpan including the newline" { const a = std.testing.allocator; const r = try deleteSpan(a, "hello world", .{ .row = 0, .col = 2 }, .{ .row = 0, .col = 5 }); diff --git a/src/nested.zig b/src/nested.zig deleted file mode 100644 index 1997d8e4..00000000 --- a/src/nested.zig +++ /dev/null @@ -1,743 +0,0 @@ -//! A pardes launched inside a pardes hands its file to the outer one. -//! -//! Every top-level instance listens on `/pardes-.sock`, where `` -//! is `$XDG_RUNTIME_DIR` or, when the session has none, `~/.local/state/pardes` -//! created 0700. NOT /tmp: this socket takes a command line and runs it, and a -//! world-writable directory means both that somebody else can plant a listener -//! at a pid we are about to guess and that a file they planted under the sticky -//! bit cannot be unlinked, so bind fails and the feature goes quietly off. -//! -//! An instance that finds an ancestor process running the same executable -//! resolves its positional argument, writes ONE line — `Look /abs/path` — to -//! that ancestor's socket and exits silently; the outer pardes runs the line -//! through executeBuiltinLine and opens a pane for it. The wire format is a -//! builtin command line because that is a language pardes already speaks: no -//! serialization, nothing to version. The receive side still filters it down -//! to `Look `, because executeBuiltinLine dispatches ANY builtin and this -//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here -//! is not something this protocol is allowed to say. -//! -//! Linux and darwin. The two differ in every primitive this needs and in none -//! of the design: /proc against libproc for the ancestor walk, SOCK_CLOEXEC -//! and accept4 against a plain socket plus an fcntl, and a `sun_path` of 108 -//! bytes against one of 104 — which is why no buffer below spells a number, -//! they are all sized from the field itself. Anywhere else the walk returns -//! null and a pardes inside a pardes opens a second session, as before. -//! -//! macOS also has a third executable in the family: the app bundle. Its binary -//! is named `pardes`, like the tty frontend, wherever the bundle is installed. -//! Executable identity therefore comes from the family name rather than its -//! path — see samePardesExecutable. -const std = @import("std"); -const builtin = @import("builtin"); -const libc = std.c; - -// std.c has getenv but neither setter; the tests below need both -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn unsetenv(name: [*:0]const u8) c_int; - -/// THE SOCKET CONVENTIONS BELOW ARE SHARED, and the ones marked `pub` are -/// shared with src/detached/server.zig — a second unix socket in the same -/// per-user directory, under a different name (`pardes-detached-.sock` -/// rather than `pardes-.sock`). They were copied into that file when it -/// landed; one directory vetted by two different predicates is exactly the -/// divergence the reasoning here is meant to prevent, so there is one of each. -pub const darwin = switch (builtin.os.tag) { - .macos, .ios, .tvos, .watchos, .visionos => true, - else => false, -}; - -/// This module is only as portable as its two ingredients: a way to name the -/// executable and parent of an arbitrary pid, and unix sockets. The detached -/// transport needs the second alone, and the same answer. -pub const supported = builtin.os.tag == .linux or darwin; - -/// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard -/// limit on this whole feature: a path that does not fit is not a socket -/// address, it is a truncated one pointing somewhere else. Taken from the -/// struct so that the buffers, the fit checks and the memcpy below cannot -/// disagree with the kernel or with each other. -pub const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; - -/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of -/// `/proc//exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux -/// spells `PPid:`. Both are same-uid readable, which is the only permission -/// an ancestor walk through one's own processes needs. -const PROC_PIDTBSDINFO: c_int = 3; -const proc_bsdinfo = extern struct { - flags: u32, - status: u32, - xstatus: u32, - pid: u32, - ppid: u32, - /// uids, gids, comm, name, the tty and the start time: filled by the - /// kernel and unread here, but the call fails unless the buffer is the - /// whole 136-byte record. - rest: [116]u8, -}; -extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; -extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; - -/// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. -/// The gap is a race only against a fork on another thread, and every caller -/// is past that: `listen` runs before the first pane exists, `acceptLine` runs -/// on a thread of its own long after spawning has settled, and the detached -/// session — which forks EVERY pane shell in the session, because the daemon -/// owns them now (`host_io.zig`) — has no other thread to race with, since it -/// services its pane ptys from the same `poll(2)` that accepts its frontends. -/// -/// CLOEXEC matters MORE for that last one than it did when a frontend forked -/// the shells: a pane shell is long-lived and arbitrary, and an inherited -/// listener would keep the session's socket bound long after the session -/// ended — the same shape as the inherited lock fd that once held a flock -/// forever. -pub fn setCloexec(fd: c_int) void { - const FD_CLOEXEC: c_int = 1; - _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); -} - -/// The longest command line this protocol carries or accepts. `Look ` plus a -/// PATH_MAX path fits with room over; anything longer cannot have come from -/// the client and is dropped rather than truncated into a different command. -pub const max_line = 4200; - -/// Where the sockets live. `$XDG_RUNTIME_DIR` first — a per-user 0700 tmpfs -/// the login session already cleans up — else `~/.local/state/pardes`, which -/// is per-user for the same reason a home directory is. NEVER /tmp: these -/// sockets take a command line, or keystrokes into a live editor. Asked by the -/// client (to derive the path), by the listener (to create and vet it), by the -/// sweeper (to scan it) and by the detached transport (all three, for its own -/// name), so it is written once. -pub fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { - if (libc.getenv("XDG_RUNTIME_DIR")) |x| - return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null; - const home = libc.getenv("HOME") orelse return null; - return std.fmt.bufPrintSentinel(buf, "{s}/.local/state/pardes", .{std.mem.span(home)}, 0) catch null; -} - -/// `/pardes-.sock`. `` is the LISTENING instance's own pid, so -/// two pardes never collide and a nested child derives the exact path from the -/// ancestor pid its tree walk found. The buffer is sun_path-sized: a longer -/// path is not a socket address at all. -pub fn socketPath(buf: *[sun_path_len]u8, pid: libc.pid_t) ?[:0]const u8 { - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = socketDir(&dir_buf) orelse return null; - // unsigned: {d} prints a leading '+' for a positive SIGNED int - return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null; -} - -/// Normalize the kernel suffix left on a running executable after its file is -/// replaced. `zig build` does this routinely while an outer session is live. -fn stripDeleted(link: []const u8) []const u8 { - const suffix = " (deleted)"; - return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link; -} - -/// The tty, SDL and macOS builds are sibling frontends of the same program. -/// Their installed names differ only by `-gui` (and, for cross builds, share -/// the same `-os-arch` tail), or not at all when one of them is the app bundle -/// — so any of them must recognise any other as an outer pardes. Paths are -/// deliberately ignored: the GUI may be installed system-wide while the tty -/// frontend is installed in the user's bin directory. -fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { - const a = stripDeleted(a_raw); - const b = stripDeleted(b_raw); - return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); -} - -/// What is left of a family name after the frontend part: `` for `pardes` and -/// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null -/// when the name is not in the family at all — `not-pardes`, `pardesfoo`, and -/// helper binaries such as `pardes-snap` are other programs. -fn familyTail(name: []const u8) ?[]const u8 { - const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) - name["pardes-gui".len..] - else if (std.mem.startsWith(u8, name, "pardes")) - name["pardes".len..] - else - return null; - if (rest.len == 0) return rest; - // Build names have exactly `-os-arch` after the frontend. Validating both - // fields keeps sibling installs flexible without mistaking pardes-snap, - // pardes-perf, and the other helper executables for editor frontends. - if (rest[0] != '-') return null; - var fields = std.mem.splitScalar(u8, rest[1..], '-'); - const os = fields.next() orelse return null; - const arch = fields.next() orelse return null; - if (fields.next() != null) return null; - if (std.meta.stringToEnum(std.Target.Os.Tag, os) == null) return null; - if (std.meta.stringToEnum(std.Target.Cpu.Arch, arch) == null) return null; - return rest; -} - -/// Two executable names in the same family. The tails have to agree — a linux -/// binary and an x86_64 one are two builds — unless one of them has no tail at -/// all, which is the untagged name the default build and, unavoidably, the app -/// bundle both produce: CFBundleExecutable is a fixed string, so the bundled -/// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name -/// records what it was. A foreign-arch ancestor cannot be running here. -fn sameFamily(a: []const u8, b: []const u8) bool { - const a_tail = familyTail(a) orelse return false; - const b_tail = familyTail(b) orelse return false; - if (std.mem.eql(u8, a, b)) return true; - return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); -} - -/// The `PPid:` field of a /proc//status blob. Deliberately NOT field 4 of -/// /proc//stat: that field is positional after `comm`, and a comm may -/// contain spaces and parentheses — a process named `sh (a b)` shifts every -/// field after it and the parse silently reads the wrong number. -fn parsePPid(status: []const u8) ?libc.pid_t { - var lines = std.mem.splitScalar(u8, status, '\n'); - while (lines.next()) |line| { - if (!std.mem.startsWith(u8, line, "PPid:")) continue; - return std.fmt.parseInt(libc.pid_t, std.mem.trim(u8, line["PPid:".len..], " \t\r"), 10) catch null; - } - return null; -} - -/// The pid in a `pardes-.sock` filename, for the startup sweep. Strictly -/// digits: parseInt alone would take `pardes-+7.sock` and `pardes--7.sock`, -/// and the sweep unlinks what this answers about. -fn sweepPid(name: []const u8) ?libc.pid_t { - if (!std.mem.startsWith(u8, name, "pardes-") or !std.mem.endsWith(u8, name, ".sock")) return null; - const digits = name["pardes-".len .. name.len - ".sock".len]; - if (digits.len == 0) return null; - for (digits) |ch| if (!std.ascii.isDigit(ch)) return null; - return std.fmt.parseInt(libc.pid_t, digits, 10) catch null; -} - -/// Name the executable behind a pid, the way this OS spells it. -fn exeOf(pid: libc.pid_t, buf: *[4096]u8) ?[]const u8 { - switch (builtin.os.tag) { - .linux => { - var name: [64:0]u8 = undefined; - const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(link, buf, buf.len); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - else => { - if (comptime !darwin) return null; - // Documented to want a PROC_PIDPATHINFO_MAXSIZE buffer, which is - // exactly this one, and to return the length it wrote. - const n = proc_pidpath(pid, buf, @intCast(buf.len)); - if (n <= 0) return null; - return buf[0..@intCast(n)]; - }, - } -} - -/// ...and its parent. -fn parentOf(pid: libc.pid_t) ?libc.pid_t { - switch (builtin.os.tag) { - .linux => { - var name: [64:0]u8 = undefined; - var buf: [4096]u8 = undefined; - const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; - const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - const got = libc.read(fd, &buf, buf.len); - _ = libc.close(fd); - if (got <= 0) return null; - return parsePPid(buf[0..@intCast(got)]); - }, - else => { - if (comptime !darwin) return null; - var info: proc_bsdinfo = undefined; - const n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, @sizeOf(proc_bsdinfo)); - // A short answer means the record this was compiled against is not - // the one the kernel filled, and `ppid` is then some other field. - if (n < @as(c_int, @sizeOf(proc_bsdinfo))) return null; - return @intCast(info.ppid); - }, - } -} - -/// The pid of the nearest ancestor running a pardes executable, or null. -/// Identity is that ancestor's executable path against our own; the tty, SDL -/// and app-bundle siblings also match when they were installed together. A -/// name alone would call every unrelated `pardes` ancestor an outer instance. -/// The hop cap is not for the process tree, which cannot loop, but because the -/// walk is driven by numbers read out of the kernel and should not be able to -/// spin on a surprising one. -pub fn outer() ?libc.pid_t { - if (comptime !supported) return null; - var self_buf: [4096]u8 = undefined; - const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null; - // A process harness may deliberately launch a fresh top-level pardes from - // inside another one. Its pid is a process-tree boundary, not an opt-out - // for the new session itself: pane shells below the child still detect it. - // This is what lets the snapshot harness exercise nested launches while - // the harness happens to be running in a real pardes pane. - const boundary = if (libc.getenv("PARDES_NESTED_BOUNDARY_PID")) |raw| - std.fmt.parseInt(libc.pid_t, std.mem.span(raw), 10) catch 0 - else - 0; - var pid = libc.getppid(); - var hops: usize = 0; - while (pid > 1 and hops < 64) : (hops += 1) { - if (pid == boundary) return null; - var buf: [4096]u8 = undefined; - if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid; - pid = parentOf(pid) orelse return null; - } - return null; -} - -/// Hand `Look [:]` to the pardes listening as `pid` and say -/// whether it landed. False for every failure — no socket file, nobody -/// accepting, a path that does not fit — because an outer instance that -/// cannot be reached (an older build, a stale path) must never cost the -/// caller its own launch. Writes and returns: the answer is a pane appearing -/// on someone else's screen, and there is nothing to wait for. -pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { - if (comptime !supported) return false; - // The protocol is one line, so a path with a line break IN it says - // something else entirely: `we\nird.txt` arrived as `Look .../we` and the - // outer instance opened a different file that happened to exist. \r goes - // too — the receive side trims a trailing one. Unsendable, not escaped: - // the caller falls through and opens the file in its own session. - if (std.mem.indexOfAny(u8, path, "\r\n") != null) return false; - var cmd_buf: [max_line]u8 = undefined; - const cmd = (if (line > 0) - std.fmt.bufPrint(&cmd_buf, "Look {s}:{d}\n", .{ path, line }) - else - std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false; - - // sun_path-sized by construction, so `sock` cannot be longer than the - // field it is about to be copied into — socketPath returns null instead. - var path_buf: [sun_path_len]u8 = undefined; - const sock = socketPath(&path_buf, pid) orelse return false; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return false; - setCloexec(fd); - defer _ = libc.close(fd); - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; - var off: usize = 0; - while (off < cmd.len) { - const n = libc.write(fd, cmd.ptr + off, cmd.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return false; - } - if (n == 0) return false; - off += @intCast(n); - } - return true; -} - -/// The two things `ensureSocketDir` has to know about a path, from whichever -/// call the platform actually offers. Darwin has fstatat and no statx; on -/// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol -/// std cannot name — so linux asks statx for the same fields. Both spellings -/// refuse to follow a symlink, which is the point of asking. -/// -/// `pub` for the detached transport, which vets the same directory and also -/// vets the SOCKET FILE with it (src/detached/server.zig `vetted`): `mode` -/// carries the type bits, so one call answers "is this a socket, ours, and -/// private" as well as it answers it for a directory. -pub const DirFacts = struct { mode: u32, uid: libc.uid_t }; - -pub fn statNoFollow(path: [:0]const u8) ?DirFacts { - if (comptime darwin) { - var st: libc.Stat = undefined; - if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null; - return .{ .mode = st.mode, .uid = st.uid }; - } else { - const linux = std.os.linux; - var stx: linux.Statx = undefined; - const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; - if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return null; - return .{ .mode = stx.mode, .uid = stx.uid }; - } -} - -/// Create the socket directory if it is missing and refuse it unless it is a -/// directory WE own with nothing granted to group or other. A planted path is -/// the whole attack on a socket that runs commands — or, for the detached -/// transport that shares this, on one that carries keystrokes into a live -/// editor — and $XDG_RUNTIME_DIR passes this untouched (the login session -/// already makes it 0700). -pub fn ensureSocketDir(dir: [:0]const u8) bool { - // mkdir -p, because the HOME branch is three levels deep and a machine - // without ~/.local/state would otherwise switch the feature off in - // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply - // EEXISTs, which is the ordinary case for the leaf too. - var partial: [sun_path_len:0]u8 = undefined; - @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); - for (1..dir.len) |i| { - if (dir[i] != '/') continue; - partial[i] = 0; - _ = libc.mkdir(partial[0..i :0], 0o700); - partial[i] = '/'; - } - _ = libc.mkdir(dir, 0o700); - // A symlink where the directory should be is exactly the plant this - // guards against, so the stat above it does not follow one. - const st = statNoFollow(dir) orelse return false; - const IFMT: u32 = 0o170000; - const IFDIR: u32 = 0o040000; - if (st.mode & IFMT != IFDIR) return false; - if (st.uid != libc.getuid()) return false; - return st.mode & 0o077 == 0; -} - -/// Unlink the socket files of pardes processes that are gone. A pardes killed -/// rather than quit runs no defer, so its file outlives it; harmless by -/// construction (bind unlinks first, a client's connect is refused) but it is -/// our own litter and the snapshot suite alone leaves ~90 behind per run. -/// Bounded: one readdir of a directory only we write to, one kill(0) each. -fn sweep(dir: [:0]const u8) void { - const d = libc.opendir(dir) orelse return; - defer _ = libc.closedir(d); - const me = libc.getpid(); - while (libc.readdir(d)) |ent| { - const pid = sweepPid(std.mem.sliceTo(&ent.name, 0)) orelse continue; - if (pid == me) continue; - // 0 = alive; EPERM = alive and someone else's. Only ESRCH is a corpse. - const rc = libc.kill(pid, @enumFromInt(0)); - if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var pbuf: [sun_path_len]u8 = undefined; - _ = libc.unlink(socketPath(&pbuf, pid) orelse continue); - } -} - -/// Bind and listen so nested instances can find us; -1 if anything fails, and -/// a pardes without a socket is simply one whose children open their own UI. -/// The path is always this process's own, so nobody outside holds a buffer of -/// it — the shells each kept one and passed it back to be unlinked, which is a -/// way for the two spellings to go out of step and for no other reason. -/// -/// CLOEXEC matters more here than on any other fd in the program: pane shells -/// are forked with forkpty and inherit everything open, and an orphaned bash -/// holding this one would keep the socket bound long after we exit — the same -/// shape as the inherited lock fd that once held a flock forever. -pub fn listen() c_int { - if (comptime !supported) return -1; - var dir_buf: [sun_path_len:0]u8 = undefined; - const dir = socketDir(&dir_buf) orelse return -1; - if (!ensureSocketDir(dir)) return -1; - sweep(dir); - // Fits by construction: socketPath writes into a sun_path-sized buffer and - // returns null rather than a truncated address. - var path_buf: [sun_path_len]u8 = undefined; - const path = socketPath(&path_buf, libc.getpid()) orelse return -1; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return -1; - setCloexec(fd); - _ = libc.unlink(path); // pid reuse: a dead pardes' file would EADDRINUSE forever - if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { - _ = libc.close(fd); - return -1; - } - // Owner-only, and BEFORE listen(2), which is the moment anyone could - // connect: the directory is already private, this is the second wall. - _ = libc.chmod(path, 0o600); - if (libc.listen(fd, 8) != 0) { - _ = libc.close(fd); - return -1; - } - return fd; -} - -/// Close the listener and take its file away. Guarded on the fd rather than on -/// the path, so a bind that FAILED cannot unlink a path this process never -/// created; anything else is a no-op, which is what --nested and every -/// unsupported build hand it. -pub fn unlisten(fd: c_int) void { - if (fd < 0) return; - _ = libc.close(fd); - var path_buf: [sun_path_len]u8 = undefined; - if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path); -} - -/// Block until a nested instance sends a `Look` line, and return it inside -/// `buf`. Null only when the listening fd itself is gone — teardown closed it, -/// or it was never a socket — because anything else (EMFILE, ECONNABORTED) -/// would otherwise kill the listener thread for the life of the process while -/// the socket stayed bound, and every later launch would exit 0 having done -/// nothing. Every accepted connection is CLOEXEC for the reason the listener -/// is. -pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { - if (comptime !supported) return null; - while (true) { - const conn = libc.accept(fd, null, null); - if (conn < 0) { - switch (libc.errno(conn)) { - .INTR => continue, - // the fd went away or never was one: nothing will ever arrive - .BADF, .INVAL, .NOTSOCK => return null, - // transient. Sleep first: EMFILE persists until some other fd - // is freed, and a bare `continue` would spin a core on it. - else => { - var ts: libc.timespec = .{ .sec = 0, .nsec = 100 * std.time.ns_per_ms }; - _ = libc.nanosleep(&ts, null); - continue; - }, - } - } - defer _ = libc.close(conn); - setCloexec(conn); - // A peer that connects and says nothing must not hold the listener: - // this is a serial accept loop, and one silent connection used to - // block every later launch until it let go. The client writes its one - // short line immediately, so a second is already generous. - const tv: libc.timeval = .{ .sec = 1, .usec = 0 }; - _ = libc.setsockopt(conn, libc.SOL.SOCKET, libc.SO.RCVTIMEO, &tv, @sizeOf(libc.timeval)); - var len: usize = 0; - while (len < buf.len) { - const n = libc.read(conn, buf.ptr + len, buf.len - len); - if (n < 0 and libc.errno(n) == .INTR) continue; - if (n <= 0) break; // EOF, or the receive timeout expired - len += @intCast(n); - if (std.mem.indexOfScalar(u8, buf[0..len], '\n') != null) break; - } - const end = std.mem.indexOfScalar(u8, buf[0..len], '\n') orelse len; - // a full buffer with no newline is an overlong line: drop it whole - // rather than run its truncation as some other command - if (end == buf.len) continue; - const line = std.mem.trimEnd(u8, buf[0..end], "\r"); - // one verb (see the file header): this socket may open things, and - // that is all it may do - if (!std.mem.startsWith(u8, line, "Look ")) continue; - return line; - } -} - -test "socket path: XDG first, then a private dir under HOME, never /tmp" { - var buf: [sun_path_len]u8 = undefined; - // The environment is process-wide and every test in this binary shares it. - // The last case below reaches the "no directory at all" branch by blanking - // both variables, and without this every later test ran without a HOME. - var xdg_buf: [4096:0]u8 = undefined; - var home_buf: [4096:0]u8 = undefined; - const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; - const home0 = if (libc.getenv("HOME")) |v| std.fmt.bufPrintSentinel(&home_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; - defer { - if (xdg0) |v| { - _ = setenv("XDG_RUNTIME_DIR", v, 1); - } else _ = unsetenv("XDG_RUNTIME_DIR"); - if (home0) |v| { - _ = setenv("HOME", v, 1); - } else _ = unsetenv("HOME"); - } - _ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1); - try std.testing.expectEqualStrings("/run/user/1000/pardes-4242.sock", socketPath(&buf, 4242).?); - _ = unsetenv("XDG_RUNTIME_DIR"); - _ = setenv("HOME", "/home/who", 1); - try std.testing.expectEqualStrings("/home/who/.local/state/pardes/pardes-4242.sock", socketPath(&buf, 4242).?); - // sun_path holds the NUL, so a directory that fills it has no socket - // address at all — say so instead of binding a truncated one. Sized from - // the field: the limit is 108 on linux and 104 on darwin, and a literal - // here would test nothing on whichever platform it was not written for. - _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** (sun_path_len - 8)), 1); - try std.testing.expect(socketPath(&buf, 4242) == null); - _ = unsetenv("XDG_RUNTIME_DIR"); - _ = unsetenv("HOME"); - try std.testing.expect(socketPath(&buf, 4242) == null); -} - -test "a rebuilt binary still matches its own running instance" { - // `zig build` under a live pardes: the outer's exe link gains the suffix, - // the new process's does not, and before this the two stopped comparing - // equal — every nested launch opened a second UI. - try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes (deleted)")); - try std.testing.expectEqualStrings("/usr/bin/pardes", stripDeleted("/usr/bin/pardes")); - try std.testing.expectEqualStrings("", stripDeleted(" (deleted)")); - // only a SUFFIX, and only the whole one - try std.testing.expectEqualStrings("/x (deleted) y", stripDeleted("/x (deleted) y")); - try std.testing.expectEqualStrings("/x (delete)", stripDeleted("/x (delete)")); -} - -test "tty and GUI sibling executables recognise each other" { - try std.testing.expect(samePardesExecutable( - "/work/zig-out/bin/pardes", - "/work/zig-out/bin/pardes-gui", - )); - try std.testing.expect(samePardesExecutable( - "/work/zig-out/bin/pardes-linux-aarch64", - "/work/zig-out/bin/pardes-gui-linux-aarch64 (deleted)", - )); - // Installation paths do not define the family. This is the ordinary - // system-GUI/user-TTY pairing and the reason this comparison uses names. - try std.testing.expect(samePardesExecutable( - "/home/who/.local/bin/pardes", - "/usr/bin/pardes-gui", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/pardes-linux-aarch64", - "/work/zig-out/bin/pardes-gui-linux-x86_64", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/not-pardes", - "/work/zig-out/bin/not-pardes-gui", - )); - try std.testing.expect(!samePardesExecutable( - "/one/bin/not-pardes", - "/two/bin/not-pardes", - )); - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/bin/pardes-snap", - "/usr/bin/pardes", - )); -} - -test "the app bundle is in the same executable family" { - // What `pardes foo.zig` typed into the bundle's own shell has to resolve: - // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., - // and nothing below zig-out is shared. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes", - )); - // ...and the SDL sibling, which reaches it by the name rule instead. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes-gui", - )); - // The case this machine actually produces: `zig build` installs the tty - // binary under its os-arch tail, and the bundle carries the same build - // under the one name CFBundleExecutable can spell. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes-macos-aarch64", - )); - // Installation location does not matter here either. - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes.app/Contents/MacOS/pardes", - "/opt/zig-out/bin/pardes", - )); - try std.testing.expect(samePardesExecutable( - "/work/zig-out/pardes/Contents/MacOS/pardes", - "/work/zig-out/bin/pardes", - )); - // Nothing here may loosen the rule for two unrelated programs that merely - // sit in a bin and a bundle of the same tree. - try std.testing.expect(!samePardesExecutable( - "/work/zig-out/other.app/Contents/MacOS/other", - "/work/zig-out/bin/pardes", - )); -} - -test "the ancestor walk reads this process's own parent" { - // The one thing a hand-written `struct proc_bsdinfo` gets wrong silently: - // a field ordering that puts something else where ppid should be still - // returns a plausible number. getppid knows the answer, so compare. - // - // Also the only check that libproc answers us at all — every caller of - // outer() treats a failure as "no outer instance", which is exactly what a - // permission problem would look like. - if (comptime !supported) return error.SkipZigTest; - try std.testing.expectEqual(libc.getppid(), parentOf(libc.getpid()).?); - // ...and that the walk terminates rather than spinning on pid 1's parent. - try std.testing.expect(parentOf(1) == null or parentOf(1).? <= 1); - - var buf: [4096]u8 = undefined; - const exe = exeOf(libc.getpid(), &buf).?; - try std.testing.expect(exe.len > 0); - try std.testing.expect(exe[0] == '/'); - // The test binary is not a pardes, so the walk must come back empty rather - // than matching some ancestor by accident. - try std.testing.expect(outer() == null); -} - -extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; -extern "c" fn rmdir(path: [*:0]const u8) c_int; - -test "a Look line survives the socket round trip" { - // Everything the protocol actually does, against a real kernel: bind, - // chmod, connect, write, accept, read, and the one-verb filter. The pure - // functions above cannot see any of it, and every primitive here is - // spelled differently on the two platforms this now supports. - if (comptime !supported) return error.SkipZigTest; - - // A private directory of our own. Not the developer's real state dir: this - // binds a socket named after a pid that is the TEST's, and sweep() unlinks - // what it finds beside it. - var tmpl: [64:0]u8 = undefined; - _ = std.fmt.bufPrintSentinel(&tmpl, "/tmp/pardes-nested-XXXXXX", .{}, 0) catch unreachable; - if (mkdtemp(&tmpl) == null) return error.SkipZigTest; - const dir = std.mem.sliceTo(&tmpl, 0); - defer _ = rmdir(tmpl[0..dir.len :0]); - - var xdg_buf: [4096:0]u8 = undefined; - const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; - defer { - if (xdg0) |v| { - _ = setenv("XDG_RUNTIME_DIR", v, 1); - } else _ = unsetenv("XDG_RUNTIME_DIR"); - } - _ = setenv("XDG_RUNTIME_DIR", tmpl[0..dir.len :0], 1); - - const fd = listen(); - try std.testing.expect(fd >= 0); - defer unlisten(fd); - - // Sent to our own pid, which is the pid listen() named the socket after. - // The client closes as it returns, and the line is already queued, so the - // single-threaded accept below finds a complete connection waiting — no - // thread and no timeout needed to prove the protocol. - try std.testing.expect(sendLook(libc.getpid(), "/etc/hosts", 42)); - var buf: [max_line]u8 = undefined; - try std.testing.expectEqualStrings("Look /etc/hosts:42", acceptLine(fd, &buf).?); - - // ...and without a line number, which is the directory and image case. - try std.testing.expect(sendLook(libc.getpid(), "/etc", 0)); - try std.testing.expectEqualStrings("Look /etc", acceptLine(fd, &buf).?); - - // The socket takes one verb. Anything else is dropped rather than run, so - // the next Look is what comes back — proving the filter skipped it without - // dropping the connection after it. - try std.testing.expect(writeLine(libc.getpid(), "Exec rm -rf /\n")); - try std.testing.expect(sendLook(libc.getpid(), "/etc/passwd", 0)); - try std.testing.expectEqualStrings("Look /etc/passwd", acceptLine(fd, &buf).?); - - // A path that cannot be one line is not escaped, it is refused. - try std.testing.expect(!sendLook(libc.getpid(), "/etc/ho\nsts", 0)); -} - -/// sendLook with the framing bypassed, so a test can put something on the wire -/// that the client would never send. -fn writeLine(pid: libc.pid_t, line: []const u8) bool { - var path_buf: [sun_path_len]u8 = undefined; - const sock = socketPath(&path_buf, pid) orelse return false; - var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); - if (fd < 0) return false; - defer _ = libc.close(fd); - if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; - return libc.write(fd, line.ptr, line.len) == @as(isize, @intCast(line.len)); -} - -test "the sweep only recognises its own socket names" { - try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?); - try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?); - try std.testing.expect(sweepPid("pardes-.sock") == null); - try std.testing.expect(sweepPid("pardes-7.sockx") == null); - try std.testing.expect(sweepPid("pardes-7") == null); - try std.testing.expect(sweepPid("bus") == null); - try std.testing.expect(sweepPid("pardes-osc133.bash") == null); - // parseInt alone would take these, and the sweep UNLINKS what it answers - try std.testing.expect(sweepPid("pardes-+7.sock") == null); - try std.testing.expect(sweepPid("pardes--7.sock") == null); - try std.testing.expect(sweepPid("pardes- 7.sock") == null); -} - -test "PPid comes off the status field, not a comm-shifted stat line" { - // the comm here contains a space AND parentheses — the exact shape that - // breaks `field 4 of /proc//stat` - const status = "Name:\tsh (a b)\nUmask:\t0022\nState:\tS (sleeping)\n" ++ - "Tgid:\t1234\nNgid:\t0\nPid:\t1234\nPPid:\t991\nTracerPid:\t0\n"; - try std.testing.expectEqual(@as(libc.pid_t, 991), parsePPid(status).?); - try std.testing.expectEqual(@as(libc.pid_t, 0), parsePPid("PPid:\t0\n").?); - try std.testing.expect(parsePPid("Name:\tinit\nTracerPid:\t0\n") == null); - try std.testing.expect(parsePPid("PPid:\tnotanumber\n") == null); - // a truncated read must not answer from a half line - try std.testing.expect(parsePPid("Name:\tsh\nPPi") == null); -} diff --git a/src/normal_input.zig b/src/normal_input.zig deleted file mode 100644 index 5fa29bea..00000000 --- a/src/normal_input.zig +++ /dev/null @@ -1,659 +0,0 @@ -//! Pure BODY-NORMAL input recognition. -//! -//! The platform/core boundary first normalizes a physical key into every -//! configured `Role` it matches. This module then owns the state machine: -//! counts, prefixes and literal character arguments. It deliberately knows -//! nothing about panes or text, so the text and PDF adapters consume exactly -//! the same semantic `Action` values. -const std = @import("std"); - -pub const Role = enum { - escape, - - prefix_goto, - prefix_view, - prefix_match, - prefix_find_fwd, - prefix_find_back, - prefix_till_fwd, - prefix_till_back, - prefix_replace, - prefix_next, - prefix_prev, - - goto_file_start, - goto_last_line, - goto_line_start, - goto_line_end, - goto_first_nonws, - goto_line_down, - goto_line_up, - goto_column, - goto_view_top, - goto_view_center, - goto_view_bottom, - goto_definition, - goto_declaration, - goto_type_definition, - goto_implementation, - goto_references, - - view_top, - view_center, - view_bottom, - view_scroll_down, - view_scroll_up, - - match_inside, - match_around, - surround_add, - surround_replace, - surround_delete, - - goto_paragraph, - add_newline, - goto_diagnostic, - goto_diagnostic_end, - - move_left, - move_right, - move_down, - move_up, - next_word_start, - prev_word_start, - next_word_end, - next_long_word_start, - prev_long_word_start, - next_long_word_end, - repeat_find, - line_start, - line_end, - line_first_nonws, - goto_line, - half_page_down, - half_page_up, - page_down, - page_up, - - insert, - append, - insert_line_start, - insert_line_end, - open_below, - open_above, - - select_mode, - select_line, - select_line_bounds, - shrink_to_line_bounds, - collapse_selection, - flip_selection, - select_all, - copy_sel_below, - copy_sel_above, - keep_primary_sel, - remove_primary_sel, - rotate_sel_fwd, - rotate_sel_back, - split_sel_newline, - merge_sels, - merge_consecutive_sels, - trim_sels, - select_regex, - split_regex, - - delete, - delete_noyank, - change, - yank, - replace_with_yank, - paste_after, - paste_before, - switch_case, - to_lowercase, - to_uppercase, - join_lines, - indent, - unindent, - format, - increment, - decrement, - comment_toggle, - undo, - redo, - - leader, - command_line, - pipe_selection, - pipe_selection_to, - insert_output, - append_output, - search, - search_next, - search_prev, -}; - -pub const Input = struct { - roles: std.EnumSet(Role) = .initEmpty(), - cp: u21, - ctrl: bool = false, - alt: bool = false, - - pub fn has(value: Input, role: Role) bool { - return value.roles.contains(role); - } - - fn literal(value: Input) ?u21 { - if (value.ctrl or value.alt or value.cp >= 0xF0000) return null; - return value.cp; - } -}; - -pub const Prefix = enum(u8) { - none, - goto, - view, - match, - find_fwd, - find_back, - till_fwd, - till_back, - replace, - next, - prev, -}; - -pub const MatchSub = enum(u8) { - none, - inside, - around, - surround_add, - surround_replace, - surround_delete, -}; - -pub const State = struct { - count: u32 = 0, - prefix: Prefix = .none, - match_sub: MatchSub = .none, - held_char: u21 = 0, - - pub fn clear(state: *State) void { - state.* = .{}; - } -}; - -/// WHERE a filter's output goes, and whether the selection is its stdin. -/// helix's `|`, `A-|`, `!` and `A-!` in one word each (commands.rs -/// `ShellBehavior`); its `$` (keep selections by exit status) is not here yet -/// because it needs a per-selection verdict rather than one atomic answer. -pub const PipeBehavior = enum { - /// `|` — stdin is the selection, and the output REPLACES it. - replace, - /// `A-|` — stdin is the selection, and the output is discarded. The text - /// is not touched at all; the point is the command's side effect. - ignore, - /// `!` — no stdin, and the output is inserted BEFORE each selection. - insert, - /// `A-!` — no stdin, and the output is appended AFTER each selection. - append, - - /// Do the selections become stdin? helix's `pipe` flag. - pub fn pipes(b: PipeBehavior) bool { - return b == .replace or b == .ignore; - } -}; - -pub const Scope = enum { once, per_selection }; -pub const Direction = enum { backward, forward }; -pub const Motion = enum { - left, - right, - down, - up, - next_word_start, - prev_word_start, - next_word_end, - next_long_word_start, - prev_long_word_start, - next_long_word_end, -}; -pub const Goto = enum { - file_start, - last_line, - line_start, - line_end, - first_nonws, - line_down, - line_up, - column, - view_top, - view_center, - view_bottom, -}; -pub const View = enum { top, center, bottom, scroll_down, scroll_up }; -pub const Find = enum { forward, backward, till_forward, till_backward }; -pub const Line = enum { start, end, first_nonws }; -pub const Page = enum { half_down, half_up, down, up }; -pub const Insert = enum { at, append, line_start, line_end, open_below, open_above }; -pub const Select = enum { - mode, - line, - line_bounds, - shrink_to_line_bounds, - collapse, - flip, - all, -}; -pub const Multi = enum { - copy_below, - copy_above, - keep_primary, - remove_primary, - rotate_forward, - rotate_backward, - split_newline, - merge, - merge_consecutive, - trim, -}; -pub const Edit = enum { - delete, - delete_noyank, - change, - yank, - replace_with_yank, - paste_after, - paste_before, - switch_case, - lowercase, - uppercase, - join_lines, - indent, - unindent, - comment_toggle, - undo, - redo, -}; -pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format }; - -pub const Counted = struct { - count: u32, - explicit: bool, -}; - -pub const Action = union(enum) { - escape, - goto: struct { target: Goto, count: u32, explicit_count: bool }, - view: View, - find: struct { kind: Find, char: u21, count: u32 }, - replace_char: u21, - match_bracket, - textobject: struct { char: u21, around: bool }, - surround_add: u21, - surround_delete: u21, - surround_replace: struct { from: u21, to: u21 }, - paragraph: struct { direction: Direction, count: u32 }, - add_newline: struct { direction: Direction, count: u32 }, - diagnostic: struct { direction: Direction, endpoint: bool }, - move: struct { motion: Motion, count: u32 }, - repeat_find: u32, - line: Line, - goto_line: Counted, - page: struct { kind: Page, count: u32 }, - insert: struct { kind: Insert, count: u32 }, - select: struct { kind: Select, count: u32 }, - multi: struct { kind: Multi, count: u32 }, - select_regex: bool, // false = select, true = split - edit: struct { kind: Edit, count: u32 }, - lsp: Lsp, - adjust_number: i64, - leader, - command_line, - /// helix's five shell commands are one action with a behaviour, because - /// they differ only in where the output lands and whether the selection is - /// stdin. See `PipeBehavior`. - pipe_selection: PipeBehavior, - search, - search_step: Direction, - - pub fn scope(value: Action) Scope { - return switch (value) { - .escape, - .multi, - .select_regex, - .leader, - .command_line, - .pipe_selection, - .search, - .search_step, - => .once, - .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, - else => .per_selection, - }, - else => .per_selection, - }; - } -}; - -pub const Result = union(enum) { - pending, - ignored, - unbound, - action: Action, -}; - -fn resultAction(value: Action) Result { - return .{ .action = value }; -} - -fn consumeCount(state: *State) Counted { - const count = state.count; - state.count = 0; - return .{ .count = @max(1, count), .explicit = count != 0 }; -} - -fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result { - state.prefix = prefix; - state.count = saved_count; - if (prefix == .match) { - state.match_sub = .none; - state.held_char = 0; - } - return .pending; -} - -/// Consume one normalized physical key. Invalid continuations are -/// distinguished from genuinely unbound top-level keys, and always clear the -/// prefix that owned them. -pub fn parse(state: *State, key: Input) Result { - if (key.has(.escape)) { - state.clear(); - return resultAction(.escape); - } - - // A digit is a count only before a command/prefix. A leading zero keeps - // its configured line-start role; after another digit it extends count. - if (state.prefix == .none and !key.ctrl and !key.alt and - key.cp >= '0' and key.cp <= '9' and - !(key.cp == '0' and state.count == 0)) - { - if (state.count < 0xffff) - state.count = state.count * 10 + key.cp - '0'; - return .pending; - } - - const counted = consumeCount(state); - const count = counted.count; - - switch (state.prefix) { - .goto => { - state.prefix = .none; - if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); - if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); - if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); - if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); - if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation }); - if (key.has(.goto_references)) return resultAction(.{ .lsp = .references }); - return .ignored; - }, - .view => { - state.prefix = .none; - if (key.has(.view_top)) return resultAction(.{ .view = .top }); - if (key.has(.view_center)) return resultAction(.{ .view = .center }); - if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); - if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); - if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up }); - if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); - if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); - if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); - if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); - return .ignored; - }, - .find_fwd, .find_back, .till_fwd, .till_back => |prefix| { - state.prefix = .none; - const char = key.literal() orelse return .ignored; - const kind: Find = switch (prefix) { - .find_fwd => .forward, - .find_back => .backward, - .till_fwd => .till_forward, - .till_back => .till_backward, - else => unreachable, - }; - return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } }); - }, - .replace => { - state.prefix = .none; - const char = key.literal() orelse return .ignored; - return resultAction(.{ .replace_char = char }); - }, - .match => { - if (state.match_sub == .none) { - if (key.has(.prefix_match)) { - state.prefix = .none; - return resultAction(.match_bracket); - } - const sub: MatchSub = if (key.has(.match_inside)) - .inside - else if (key.has(.match_around)) - .around - else if (key.has(.surround_add)) - .surround_add - else if (key.has(.surround_replace)) - .surround_replace - else if (key.has(.surround_delete)) - .surround_delete - else { - state.prefix = .none; - return .ignored; - }; - state.match_sub = sub; - return .pending; - } - const char = key.literal() orelse { - state.clear(); - return .ignored; - }; - if (state.match_sub == .surround_replace and state.held_char == 0) { - state.held_char = char; - return .pending; - } - const sub = state.match_sub; - const from = state.held_char; - state.clear(); - return switch (sub) { - .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }), - .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }), - .surround_add => resultAction(.{ .surround_add = char }), - .surround_delete => resultAction(.{ .surround_delete = char }), - .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }), - .none => unreachable, - }; - }, - .next, .prev => |prefix| { - state.prefix = .none; - const direction: Direction = if (prefix == .next) .forward else .backward; - if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } }); - if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); - if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); - if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); - return .ignored; - }, - .none => {}, - } - - // Prefix setters retain the count for their continuation. - if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); - if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); - if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); - if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); - if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); - if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0); - if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0); - if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); - if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); - if (key.has(.prefix_match)) return armPrefix(state, .match, 0); - - if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); - if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); - if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } }); - if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } }); - if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } }); - if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } }); - if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } }); - if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } }); - if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } }); - if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } }); - if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count }); - if (key.has(.line_start)) return resultAction(.{ .line = .start }); - if (key.has(.line_end)) return resultAction(.{ .line = .end }); - if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws }); - if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted }); - if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); - if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); - if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); - if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); - - if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } }); - if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } }); - if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } }); - if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } }); - if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } }); - if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } }); - - if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } }); - if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } }); - if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } }); - if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } }); - if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } }); - if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } }); - if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } }); - - if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } }); - if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } }); - if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } }); - if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } }); - if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } }); - if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } }); - if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } }); - if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); - if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); - if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); - if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); - if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); - - if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); - if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); - if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } }); - if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } }); - if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } }); - if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } }); - if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } }); - if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } }); - if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); - if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); - if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); - if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); - if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); - if (key.has(.format)) return resultAction(.{ .lsp = .format }); - if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) }); - if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) }); - if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } }); - if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } }); - if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } }); - - if (key.has(.leader)) return resultAction(.leader); - if (key.has(.command_line)) return resultAction(.command_line); - if (key.has(.pipe_selection)) return resultAction(.{ .pipe_selection = .replace }); - if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); - if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); - if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); - if (key.has(.search)) return resultAction(.search); - if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); - if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); - return .unbound; -} - -fn input(cp: u21, roles: []const Role) Input { - return .{ .cp = cp, .roles = .initMany(roles) }; -} - -test "counts survive prefixes and identical parser actions can feed both adapters" { - var text: State = .{}; - var pdf: State = .{}; - const sequence = [_]Input{ - input('1', &.{}), - input('2', &.{}), - input('g', &.{ .prefix_goto, .goto_file_start }), - input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }), - }; - for (sequence[0 .. sequence.len - 1]) |key| { - try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key)); - } - const ta = parse(&text, sequence[sequence.len - 1]); - const pa = parse(&pdf, sequence[sequence.len - 1]); - try std.testing.expectEqualDeep(ta, pa); - try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{ - .target = .line_down, - .count = 12, - .explicit_count = true, - } } }, ta); - try std.testing.expectEqual(State{}, text); - try std.testing.expectEqual(State{}, pdf); -} - -test "invalid continuations are ignored and clear prefix plus count" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto}))); - try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{}))); - try std.testing.expectEqual(State{}, state); - try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down}))); -} - -test "literal arguments retain conflicting command characters" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after}))); - - try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace}))); - try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next}))); - try std.testing.expectEqual(State{}, state); -} - -test "modified and special keys cannot satisfy literal continuations" { - var state: State = .{}; - _ = parse(&state, input('r', &.{.prefix_replace})); - try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true })); - try std.testing.expectEqual(State{}, state); - _ = parse(&state, input('f', &.{.prefix_find_fwd})); - try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 })); - try std.testing.expectEqual(State{}, state); -} - -test "replace accepts a Unicode literal" { - var state: State = .{}; - try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace}))); - try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{}))); - try std.testing.expectEqual(State{}, state); -} - -test "once versus per-selection is semantic action metadata" { - try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope()); - try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope()); - try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope()); - try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope()); -} diff --git a/src/output_pane.zig b/src/output_pane.zig deleted file mode 100644 index f4721136..00000000 --- a/src/output_pane.zig +++ /dev/null @@ -1,695 +0,0 @@ -//! Output panes: acme's +Errors, a file pane with no file behind it, holding -//! text the core produced itself (+Search results, +Help, the LSP answer -//! buffers). It IS a file pane — every mode, motion, chord and look works for -//! free — and that reuse is the point. -//! -//! What it is NOT any more is a file pane with a bool on it. An output buffer -//! remembers THE COMMAND THAT OPENED IT (`Origin`), and every special case it -//! gets is one `traits` lookup on that field. Before this, a dozen places -//! re-derived what a pane was from the outside, each asking a different wrong -//! question: `endsWith(path, "+Search")` (the NAME decided what a pane WAS, -//! which is backwards — a name is a consequence), a `search_kind` field on the -//! pane that ran the search rather than on the buffer that answered it, and -//! `f.output` booleans sprinkled through kind-agnostic layout code. Now the -//! buffer knows, and the table below is the whole answer: one screen you read -//! top to bottom to see every way an output pane differs from a file, and one -//! row to add to introduce another kind. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const file_pane = @import("file_pane.zig"); -const modal = @import("modal.zig"); -const builtins = @import("builtins.zig"); -const runtime_config = @import("runtime_config.zig"); -const effect_sources = @import("effect_sources.zig"); -const Builtin = builtins.registry.Builtin(); -const config = @import("config.zig"); -const build_options = @import("pardes_config"); -const dump = @import("dump.zig"); -const lsp = @import("lsp/lsp.zig"); -const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); - -/// the installed fonts, for openFonts. GUI only, behind the same comptime -/// branch builtins.zig imports it through — see the note there. -const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; - -/// What opened this buffer — THE field, and the only input to `traits`. -/// -/// Two vocabularies because the core has exactly two: a `Builtin` is a word -/// you can execute anywhere, and that covers Find, Grep, Help and every -/// language query that has a name (Hover, Diagnostics...). The rest are KEYS — -/// helix binds the five gotos and `=` as motions and `/` as a search input, -/// and a motion has no word to click. Recording the key's `lsp.Kind` (or -/// `.search` for the bare `/`) is not a parallel tag enum: both are the values -/// the caller already holds when it opens the buffer. -pub const Origin = union(enum) { - cmd: Builtin, - query: lsp.Kind, - /// the bare `/` — the pane's own text, searched in core - search, - /// acme's `+Errors`: whatever a script wrote to a pane's `errors` file or - /// to the top-level `cons`. Not a command at all — the third vocabulary - /// is "somebody else's output", and it has no word to click because the - /// writer is a process, not a keystroke. - errors, -}; - -/// The exact command identity. A search prompt is bounded by the same one-line -/// cap as a tag, so retaining that whole bound keeps refill and dump/restore -/// identity exact without adding a per-output allocation. -pub const max_arg = dump.max_origin_arg; - -/// An output buffer's own state, hung off `file_pane.State.output`. -pub const Output = struct { - from: Origin, - /// the command's ARGUMENT: the pattern a Grep matched, the new name a - /// Rename took, the SPC prefix a Help lists. Inline rather than allocated: - /// tag input already enforces this exact cap. - arg_buf: [max_arg]u8 = undefined, - arg_len: u16 = 0, - - pub fn arg(o: *const Output) []const u8 { - return o.arg_buf[0..o.arg_len]; - } -}; - -pub fn setArg(o: *Output, text: []const u8) error{ArgumentTooLong}!void { - if (text.len > max_arg) return error.ArgumentTooLong; - o.arg_len = @intCast(text.len); - @memcpy(o.arg_buf[0..o.arg_len], text); -} - -/// Every way an output pane differs from a file pane. Manual builtins already -/// declare this exact row beside their implementation; use that schema here -/// too instead of copying it into a parallel struct. -pub const Traits = builtins.OutputTraits; - -/// A REAL file pane, as a row of the same table — so kind-agnostic code asks -/// one question and gets one answer whichever it is holding. `name` is unused: -/// a file already has a path. -const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; - -/// THE TABLE. Everything above, answered from the command that opened the -/// buffer. Exhaustive on purpose: a new `lsp.Kind` or a new output-opening -/// builtin should not compile until someone has said what its buffer does. -pub fn traits(o: Origin) Traits { - return switch (o) { - // rows are `location text`, so n/N walk them - .search => .{ .name = config.search_buffer, .steps = true }, - // A transcript, not a list: rows are whatever a program printed, so - // n/N walks its words like any prose buffer, and there is nothing to - // Save — acme's +Errors is not a file either. - .errors => .{ .name = config.errors_buffer, .doc = true }, - .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, - .query => |k| switch (k) { - .hover => .{ .name = config.hover_buffer }, - // prose: an action list, a diff, a report about the backend - .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, - // Rename usually resolves to edit records the core consumes before - // any buffer opens; what RENDERS is the multi-file PREVIEW — one - // location row per would-be edit — which n/N step like any list. - .rename => .{ .name = config.search_buffer, .steps = true }, - .definition, .declaration, .type_definition, .implementation, .references => .{ - .name = config.search_buffer, - .steps = true, - .jumps = true, - }, - // The hierarchy kinds behave like references: a list of places, - // and a lone answer (one caller, one subtype) is a jump. - .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ - .name = config.search_buffer, - .steps = true, - .jumps = true, - }, - // completion lists WHAT COULD GO HERE, one row per candidate's - // definition. It does not jump on a single row where the gotos do: - // a goto answers a question whose answer is a place, so landing - // there IS the answer, whereas the question here is "what can I - // write", and being teleported into the one candidate's - // declaration instead of being shown it is not that. - .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ - .name = config.search_buffer, - .steps = true, - }, - }, - }; -} - -/// The same table asked of a file pane's `output` field, null (a real file) -/// included. This is what the kind-agnostic code in pardes.zig calls. -pub fn fileTraits(out: ?Output) Traits { - return traits((out orelse return file_row).from); -} - -/// How much of a row ONE n/N step selects (Pardes.lookSpanIn). Derived from -/// the two columns above rather than a third one, because it is not a fact -/// about a buffer — it is what those facts MEAN to the walk. -pub const Grain = enum { - /// every look-able word, several to a line, in document order. Free text: - /// a terminal's scrollback, a file, a PDF, and an output buffer of PROSE, - /// where the place you want may be mid-sentence. - word, - /// the location at the head of the row, and one stop per row. A results - /// buffer is a LIST: the words after a row's location are the matched - /// text, and stepping onto them was stepping onto the same hit twice. - line, - /// the whole row: a command list, where the line is the word. - whole, -}; - -/// The grain of a pane's rows, off its `output` field — null (a real file) -/// included, which is why a file pane is unaffected by any of this. -pub fn grain(out: ?Output) Grain { - const tr = fileTraits(out); - if (tr.commands) return .whole; - return if (tr.steps) .line else .word; -} - -/// How the dump spells an origin. A WORD, never an integer, for the reason the -/// dump already stores tag words: reordering builtins.zig stays free. Nothing -/// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is -/// neither. -pub fn word(o: Origin) []const u8 { - return switch (o) { - .cmd => |b| @tagName(b), - .query => |k| @tagName(k), - .search => "/", - .errors => config.errors_buffer, - }; -} - -/// the inverse; null for "" (a real file) and for a word this build no longer -/// has, which is a dump from another version and not a reason to fail a load -pub fn fromWord(w: []const u8) ?Origin { - if (w.len == 0) return null; - if (std.mem.eql(u8, w, "/")) return .search; - if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; - if (std.meta.stringToEnum(Builtin, w)) |b| - if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; - if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; - return null; -} - -/// Is the results buffer `pane`'s n/N is armed on the one `from` filled? -/// `]d`/`[d` are the only keys that care WHICH search is showing — they step -/// the diagnostics list when it is up and ask for one when it is not — and -/// this is how they ask now that the buffer remembers: `search_pane` is a -/// SLOT, so this doubles as the check that the slot is still ours. -pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { - const rp = p.panes[pane.search_pane orelse return false] orelse return false; - const f = rp.file orelse return false; - const o = f.output orelse return false; - return std.meta.eql(o.from, from); -} - -/// Open one. `content` is gpa-owned and adopted; the NAME comes from the table -/// (the caller says what ran, not what to call it) and carries `dir` so looks -/// inside the buffer resolve like anywhere else. -pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { - const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ - std.mem.trimEnd(u8, dir, "/"), traits(from).name, - }); - errdefer p.gpa.free(path); - var out: Output = .{ .from = from }; - try setArg(&out, arg); - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = out }; - pane.cur_pinned = true; - return pane; -} - -/// Land a freshly produced list of rows in the buffer it belongs in — the one -/// rule every results buffer follows, whichever side of the core made them. -/// -/// The SAME command asked again REFILLS the list it already opened rather than -/// stacking a byte-identical twin under the pane. That was runSearch's rule -/// from the start (right-clicking a word in four places is one +Search walked -/// four times) and language answers turned out to need it far more urgently: -/// Tab after a dot makes a query an ordinary typing keystroke, and without the -/// refill twenty of them fill every slot and the key is eaten for the rest of -/// the session — see docs/lsp.md. -/// -/// What "the same command" means comes off the ORIGIN. A search is identified -/// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves -/// bar's open; a language query is asked about a different symbol every time -/// with the same (usually empty) arg, so the arg cannot tell two apart and the -/// KIND is the natural unit — a second `gr` replaces the first list. Same -/// directory only, because the rows are written relative to it, and never the -/// asking pane itself (a `/` inside a +Search writes its own rows). -/// -/// `content` is gpa-owned: adopted by the buffer, or freed here when there is -/// nowhere to put it. `anchor` is the row n/N step from, null for the top. -pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - const by_arg = std.meta.activeTag(from) != .query; - for (p.panes, 0..) |slot, i| { - if (i == id) continue; - const rp = slot orelse continue; - const rf = if (rp.file) |*f| f else continue; - const o = if (rf.output) |*x| x else continue; - if (!std.meta.eql(o.from, from)) continue; - if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; - if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; - try setArg(o, arg); - // a refill that changes NOTHING keeps its place: a right click on an - // already-armed word is an `n`, and throwing the list back to the top - // only to scroll down to the stepped row is a jump with no information - // in it. - const same = std.mem.eql(u8, rf.content, content); - file_pane.setContent(p, rf, content); - if (!same) rf.scroll = 0; - p.active = id; - if (traits(from).steps) { - pane.search_pane = i; - pane.search_row = anchor; - p.armLookWalk(i); - } - return; - } - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - p.placeDoc(id, free, np); - p.active = id; - // prose is not a list of locations: n/N over a hover blurb would step to - // nowhere, so only stepping buffers arm the stepper — and WHICH command - // filled it is the buffer's own record, not a field on the asking pane. - if (traits(from).steps) { - pane.search_pane = free; - pane.search_row = anchor; - p.armLookWalk(free); - } -} - -/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text, -/// one row per location, oldest first — the same `location text` shape every -/// results buffer here has, which is what buys n/N stepping and Look-on-a-row -/// for nothing: the leading word is an ordinary look target, and the ordinary -/// look path is what goes there. -/// -/// A RENDERING, never a second list. The rows are spelled from the stack at -/// the moment you ask and go stale the moment you jump, exactly like a search -/// result — which is also why this opens a fresh buffer per press instead of -/// refreshing one the way Help does: Help is a document, this is a snapshot. -/// -/// How a location is spelled is the rule runSearch already follows: a REAL -/// file names itself (its path is absolute, so the row resolves from any -/// pane's directory), and everything else — a terminal, an output buffer, an -/// image — has no file to point at and gets `@pN`. The trailing text is the -/// content line for anything holding text, else the pane's directory: enough -/// to recognise the place without opening it. -pub fn openJumps(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (p.jumps[0..p.njumps]) |j| { - const jp = p.panes[j.pane] orelse continue; - var idbuf: [16]u8 = undefined; - const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; - const has_path = if (jp.file) |f| f.output == null else pdf_path != null; - const loc: []const u8 = if (has_path) - (if (jp.file) |f| f.path else pdf_path.?) - else - std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; - const what: []const u8 = if (jp.file) |f| - std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") - else if (jp.image) |iv| - iv.path - else if (pdf_path) |path| - path - else - jp.cwdSlice(); - var cut = @min(what.len, 120); - while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; - if (j.line == 0) - try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) - else - try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); - } - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .Jumplist }, content); -} - -/// The ThemeSel builtin: the theme ring written out as one `Theme ` row -/// per theme — the ordinary builtin with its argument, exactly the line you -/// would type — into a buffer whose `commands` trait says the rows are words -/// and not places. n/N therefore select each row WHOLE and Tab runs it, so -/// walking the list is trying the themes on and stopping on one is choosing -/// it: no picker mode, no preview state, nothing to commit or cancel. -/// -/// The command's own name comes from the runtime setting descriptor rather -/// than a second literal. The descriptor generates the builtin too, so a -/// rename cannot leave picker rows naming a command that is gone. -pub fn openThemes(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (pardes.themes) |t| - try out.writer.print(comptime runtime_config.findAction(.theme).?.word ++ " {s}\n", .{t.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .ThemeSel }, content); -} - -/// The FontSel builtin: openThemes over the fonts installed on the machine -/// instead of the themes compiled into the binary — one `Font ` row -/// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and -/// stopping picks one. Everything that makes that work is already above; this -/// is the same one-pass writer pointed at a different list. -/// -/// Only where the shell draws its own text. The same `font_picker` availability -/// bit that generates the Font/FontSel builtins keeps non-GUI builds from -/// analysing font discovery here. -pub fn openFonts(p: *Pardes, id: usize) !void { - if (builtins.capabilities.font_picker) { - const arena = p.scratch.allocator(); - const font_list = fonts.list(arena, null); - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - for (font_list) |f| - try out.writer.print(comptime runtime_config.findAction(.font).?.word ++ " {s}\n", .{f.name}); - const content = try out.toOwnedSlice(); - try openStepped(p, id, .{ .cmd = .FontSel }, content); - } -} - -/// Open a buffer n/N will walk, and arm them on it: the shared tail of every -/// builtin that answers with a list. `content` is gpa-owned and adopted by the -/// new pane, or freed here if opening it fails. -/// -/// Focus stays with the pane that ASKED, exactly as it does after a search: -/// n/N are read there, and they step the buffer they just armed. -fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, "", content); - p.placeDoc(id, free, np); - p.active = id; - pane.search_pane = free; - pane.search_row = null; - p.armLookWalk(free); -} - -/// The Help builtin: THE INDEX of builtins — every one of them, and every way -/// to run it — filtered to what `prefix` can still reach, written into an -/// output buffer (acme's +Errors). Ordinary text, so the names in it are LIVE: -/// middle-click `Tutor` there and the tutor opens. Reuses the open +Help -/// buffer instead of piling panes up, and focus follows: you asked to read it. -/// -/// ONE builtin and not two. The complete index and the mid-chord "what can -/// `SPC h` still reach" are the same array (pardes.builtin_rows) read with a -/// different prefix — the empty one matches every row, including the builtins -/// SPC cannot reach at all, so the reference page IS the filter's degenerate -/// case. A second builtin would have been a second renderer over a superset of -/// these rows, and the two would have drifted the first time a column moved. -fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { - const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; - const group_header = "pardes builtins under SPC"; - // The LANGUAGE KEYS are the one part of the keymap Help would otherwise - // never show: they are motions and modes, not words, so no builtin row - // carries them — yet they are the keys a reader comes looking for. Full - // listing only; a mid-chord `SPC l` view stays a pure filter. - const language_footer = - "\nlanguage keys (motions, not words):\n" ++ - "gd gD gy gi gr goto: definition,\n" ++ - " declaration, type-def,\n" ++ - " implementation, refs\n" ++ - "]d [d ]D [D diagnostics: next,\n" ++ - " prev, last, first\n" ++ - "= format (applies, one\n" ++ - " undo step)\n" ++ - "Tab after a . completion, in insert\n" ++ - "C-left-click definition, by mouse\n" ++ - "SPC l ... hover, rename, symbols,\n" ++ - " calls, types: above\n"; - var len: usize = if (prefix.len == 0) - full_header.len + language_footer.len - else - group_header.len + prefix.len * 2 + 2; - for (pardes.builtin_rows) |row| { - // a path-less builtin filters as the empty path: in the full listing - // (which starts with nothing) and out of every group - if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; - len += row.line.len + 1; - } - const content = try gpa.alloc(u8, len); - var at: usize = 0; - if (prefix.len == 0) { - @memcpy(content[0..full_header.len], full_header); - at = full_header.len; - } else { - @memcpy(content[0..group_header.len], group_header); - at = group_header.len; - for (prefix) |c| { - content[at] = ' '; - content[at + 1] = c; - at += 2; - } - content[at] = '\n'; - content[at + 1] = '\n'; - at += 2; - } - for (pardes.builtin_rows) |row| { - if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; - @memcpy(content[at..][0..row.line.len], row.line); - at += row.line.len; - content[at] = '\n'; - at += 1; - } - if (prefix.len == 0) { - @memcpy(content[at..][0..language_footer.len], language_footer); - at += language_footer.len; - } - std.debug.assert(at == content.len); - return content; -} - -pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { - const content = try helpContent(p.gpa, prefix); - // content is handed off unfreed on purpose: openRead adopts it or frees - // it, and nothing between the alloc above and this line can fail. - return openRead(p, id, .{ .cmd = .Help }, prefix, content); -} - -test "full Help renders every builtin row, then the language keys" { - const content = try helpContent(std.testing.allocator, ""); - defer std.testing.allocator.free(content); - - // FIRST blank line: the end of the header (the footer opens with one too) - const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse - return error.MissingHelpHeader) + 2 ..]; - var lines = std.mem.splitScalar(u8, body, '\n'); - for (pardes.builtin_rows) |row| - try std.testing.expectEqualStrings(row.line, lines.next() orelse - return error.MissingBuiltinHelpRow); - // ...and after the last row, the language-keys section: the one part of - // the keymap no builtin row can carry, closing the page. - try std.testing.expectEqualStrings("", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse - return error.MissingLanguageKeys); - try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); - // the group view stays a pure filter: no footer under a prefix - const group = try helpContent(std.testing.allocator, "l"); - defer std.testing.allocator.free(group); - try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); -} - -/// The complete live Config report: the generated settings and the host facts -/// needed to interpret them. The startup path remains ordinary selectable text -/// in the report, so Look still opens the exact file the launcher consulted. -pub fn openConfig(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try runtime_config.writeReport(&out.writer, .{ - .startup_config_path = p.opts.startup_config_path, - .platform = @tagName(pardes.platform), - .theme_name = p.theme().name, - .compiled_default_shell = config.default_shell, - .gui_shader_source_mode = if (gui_shader_source_mode) |mode| - mode.label() - else - null, - .hover_delay_frames = config.look_preview_delay_frames, - .native_images = p.native_images, - .capabilities = builtins.capabilities, - .state = &p.settings, - }); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Config }, "", content); -} - -/// The message-row log, oldest first — the lines that were said in passing and -/// then cleared by the next keystroke. -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.messageLog(i)) |m| : (i += 1) { - if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); - try out.writer.writeAll(m.slice()); - if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); - try out.writer.writeByte('\n'); - } - if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Messages }, "", content); -} - -/// The version banner plus the embedded CHANGELOG, so an installed binary can -/// say what it is and what changed without a repository beside it. -pub fn openChangelog(p: *Pardes, id: usize) !void { - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("pardes {s}\n\n", .{build_options.version}); - try out.writer.writeAll(@embedFile("CHANGELOG.md")); - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .Changelog }, "", content); -} - -/// Print the implementation that this build actually uses for one effect. -/// Sources are build inputs embedded as bytes, so this stays useful from an -/// installed binary with no repository beside it. -pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { - const name = std.mem.trim(u8, argument, " \t\r\n"); - const setting = runtime_config.find(name) orelse return error.UnknownEffect; - switch (setting.action) { - .transition, .scene => {}, - else => return error.NotAnEffect, - } - if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; - const segments = effect_sources.forSetting(setting) orelse - return error.EffectUnavailable; - - var out: std.Io.Writer.Allocating = .init(p.gpa); - errdefer out.deinit(); - try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); - if (gui_shader_source_mode) |mode| - try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); - try out.writer.writeByte('\n'); - for (segments) |segment| { - try out.writer.print("--- {s} ---\n", .{segment.path}); - try out.writer.writeAll(segment.source); - if (!std.mem.endsWith(u8, segment.source, "\n")) try out.writer.writeByte('\n'); - try out.writer.writeByte('\n'); - } - const content = try out.toOwnedSlice(); - return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); -} - -/// Open a buffer you READ, and go there: the shared tail of every builtin -/// whose answer is a document rather than a list. Asking again REFRESHES the -/// one already open instead of stacking a twin beside it — found by its -/// ORIGIN, never by matching its name, for the reason the whole file exists. -/// -/// The mirror of `openStepped`, and the difference is the two lines at the -/// ends: focus comes HERE (you asked to read it) where a results buffer -/// leaves you in the pane that asked, and n/N are not armed, because prose -/// has nowhere to step to. -/// -/// `content` is gpa-owned: adopted by the buffer, or freed here when there is -/// nowhere to put it. -/// Put a report in this directory's `+Errors` buffer — acme's own name for -/// output that came from the PROGRAM rather than from a word somebody clicked. -/// Refills the one already open rather than stacking a twin, which is what a -/// second failed filter wants: the newest reason is the one being read. -pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { - return openRead(p, id, .errors, "", content); -} - -fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { - errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; - for (p.panes, 0..) |slot, i| { - const hp = slot orelse continue; - const hf = if (hp.file) |*f| f else continue; - const ho = if (hf.output) |*o| o else continue; - if (!std.meta.eql(ho.from, from)) continue; - try setArg(ho, arg); - file_pane.setContent(p, hf, content); - hf.scroll = 0; - hp.cur_row = 0; - hp.msel.active = false; - p.active = i; - return; - } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - p.placeDoc(id, free, np); - p.active = free; -} - -test "dump origins accept only builtins that actually own output panes" { - try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); - try std.testing.expect(fromWord("Kill") == null); - try std.testing.expect(fromWord("Theme") == null); -} - -test "result refill identity retains the full bounded argument" { - const p = try Pardes.init(std.testing.allocator, .{ - .tty_only = true, - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - - var first: [max_arg]u8 = @splat('a'); - var second = first; - first[200] = 'x'; - second[200] = 'y'; - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "first\n"), - null, - ); - const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; - const next_slot = p.freeSlot(); - - try fillResults( - p, - 0, - "/tmp", - .search, - &first, - try p.gpa.dupe(u8, "refilled\n"), - null, - ); - try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); - try std.testing.expectEqual(next_slot, p.freeSlot()); - try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); - - try fillResults( - p, - 0, - "/tmp", - .search, - &second, - try p.gpa.dupe(u8, "second\n"), - null, - ); - try std.testing.expect(p.panes[0].?.search_pane.? != first_id); - - var output: Output = .{ .from = .search }; - var oversized: [max_arg + 1]u8 = @splat('z'); - try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); - const slot_before_error = p.freeSlot(); - try std.testing.expectError( - error.ArgumentTooLong, - fillResults( - p, - 0, - "/tmp", - .search, - &oversized, - try p.gpa.dupe(u8, "must be freed\n"), - null, - ), - ); - try std.testing.expectEqual(slot_before_error, p.freeSlot()); -} diff --git a/src/output_pane_integration_test.zig b/src/output_pane_integration_test.zig deleted file mode 100644 index e7a46a03..00000000 --- a/src/output_pane_integration_test.zig +++ /dev/null @@ -1,338 +0,0 @@ -//! End-to-end output-pane tests. Production output identity, rendering, and -//! builders stay in output_pane.zig; this module exercises their direct seam -//! with Pardes builtins and the generic n/N input walk. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const output_pane = @import("output_pane.zig"); -const runtime_config = @import("runtime_config.zig"); -const builtins = @import("builtins.zig"); -const config = @import("config.zig"); -const modal = @import("modal.zig"); - -const Pardes = pardes.Pardes; -const Key = pardes.Key; -const platform = pardes.platform; -const font_picker = pardes.font_picker; -const fonts = if (font_picker) @import("fonts.zig") else struct {}; - -test "Config prints the startup path and refreshes its one output" { - const path = "/home/pardes-test/.config/pardes/init"; - const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - const opened = p.active; - const out = p.panes[opened].?.file.?; - for ([_][]const u8{ - "Startup config: " ++ path ++ "\n", - "Theme: helix\n", - "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n", - "Shell effective (last spawn): (none)\n", - "Shell pending: on\n", - }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null); - try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path)); - try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from); - - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - try std.testing.expectEqual(opened, p.active); - var buffers: usize = 0; - for (p.panes) |slot| { - const f = (slot orelse continue).file orelse continue; - const origin = (f.output orelse continue).from; - buffers += @intFromBool(std.meta.eql(origin, output_pane.Origin{ .cmd = .Config })); - } - try std.testing.expectEqual(@as(usize, 1), buffers); -} - -test "Config reports the absence of a per-user config path" { - const p = try Pardes.init(std.testing.allocator, .{}); - defer p.deinit(); - try std.testing.expect(p.executeBuiltinLine(0, "Config")); - const report = p.panes[p.active].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, report, "no per-user config path") != null); -} - -test "EffectCode opens the embedded implementation used by this backend" { - if (comptime platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide")); - const out = p.panes[p.active].?.file.?; - try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path)); - try std.testing.expectEqual(output_pane.Origin{ .cmd = .EffectCode }, out.output.?.from); - try std.testing.expectEqualStrings("PanelSlide", out.output.?.arg()); - try std.testing.expect(std.mem.indexOf(u8, out.content, "pub const Transition = enum") != null); - const backend_source = switch (platform) { - .tty => "src/tty/panel_compositor.zig", - .gui => if (@import("pardes_config").gui_shader_sources_prebuilt) - "shaders/prebuilt/ui.vert.glsl" - else - "shaders/ui.vert.glsl", - .macos => "src/macos/Sources/ScenePostprocessor.swift", - // Neither backend builds this native test binary: the browser shell is wasm and the P4 - // firmware is a freestanding object, so no `unit-test` run can ever land here. - .web, .esp32p4 => unreachable, - }; - try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null); -} - -test "every enabled setting builtin mutates the State Config reports" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent); - - var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer font_arena.deinit(); - var chosen_font: ?[]const u8 = null; - var buf: [512]u8 = undefined; - for (runtime_config.settings) |setting| { - if (!setting.enabled(builtins.capabilities)) continue; - const command: []const u8 = switch (setting.action) { - .theme => try std.fmt.bufPrint(&buf, "{s} acme", .{setting.word}), - .shell => try std.fmt.bufPrint(&buf, "{s} fish", .{setting.word}), - .tagline_size => try std.fmt.bufPrint(&buf, "{s} 67", .{setting.word}), - .font => continue, - else => setting.word, - }; - try std.testing.expect(p.executeBuiltinLine(p.active, command)); - } - if (comptime font_picker) { - const before_invalid = p.settings.font.tagline_percent; - try std.testing.expect(p.executeBuiltinLine(p.active, "TaglineSize 0")); - try std.testing.expectEqual(before_invalid, p.settings.font.tagline_percent); - const installed = fonts.list(font_arena.allocator(), null); - if (installed.len > 0) { - chosen_font = installed[0].name; - const command = try std.fmt.bufPrint(&buf, "Font {s}", .{installed[0].name}); - try std.testing.expect(p.executeBuiltinLine(p.active, command)); - } - } - - try std.testing.expect(p.executeBuiltinLine(p.active, "Config")); - const report = p.panes[p.active].?.file.?.content; - for ([_][]const u8{ - "Colors: off\n", - "Wrap: off\n", - "Tagbottom: on\n", - "Debug: on\n", - "Theme: acme\n", - "Shell requested (new panes): fish\n", - }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null); - const transition = if (builtins.capabilities.panel_transitions) - try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{ - runtime_config.findAction(.{ .transition = p.settings.panel_transition }).?.word, - }) - else - "Panel transition: unsupported\n"; - try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); - const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { - const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status }); - try std.testing.expect(std.mem.indexOf(u8, report, line) != null); - } - const tagline = if (!builtins.capabilities.tagline_font_size) - "TaglineSize: unsupported\n" - else - try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{ - p.settings.font.tagline_percent, - if (builtins.capabilities.font_picker) "" else " (build-time only)", - }); - try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null); - if (chosen_font) |name| { - try std.testing.expect(std.mem.indexOf(u8, report, name) != null); - try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null); - } -} - -fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pattern: []const u8) !usize { - const rows = try p.gpa.dupe(u8, - \\build.zig:1:1 first - \\(mise.toml) and build.zig.zon:3:2-9 two on one row - \\nothing look-able on this row at all - \\uucode_config.zig:7:1 last - \\ - ); - try output_pane.fillResults(p, id, cwd, .search, pattern, rows, null); - return p.panes[id].?.search_pane orelse error.MissingResults; -} - -const ProjectPaths = struct { cwd: []const u8, boot: []const u8 }; - -fn projectPaths(cwd_buf: *[4096]u8, path_buf: *[4096]u8) !ProjectPaths { - const raw = std.c.getcwd(cwd_buf, cwd_buf.len) orelse return error.GetCwdFailed; - const cwd = std.mem.span(@as([*:0]u8, @ptrCast(raw))); - return .{ .cwd = cwd, .boot = try std.fmt.bufPrint(path_buf, "{s}/mise.toml", .{cwd}) }; -} - -fn selectedOutputText(pane: *const pardes.Pane) ?[]const u8 { - const file = pane.file orelse return null; - if (!pane.vsel.active or pane.vsel.row != pane.cur_row or pane.cur_row < 0) return null; - const line = modal.lineSlice(file.content, @intCast(pane.cur_row)); - const lo: usize = @intCast(@max(0, @min(pane.vsel.col, pane.cur_col))); - const hi: usize = @intCast(@max(0, @max(pane.vsel.col, pane.cur_col))); - if (lo >= line.len) return ""; - return line[lo..@min(line.len, hi + 1)]; -} - -test "n/N selects one output location per row and opens nothing" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - const rid = try walkFixture(p, p.active, paths.cwd, "one"); - const results = p.panes[rid].?; - const panes_before = p.freeSlot(); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(panes_before, p.freeSlot()); - try std.testing.expect(results.vsel.active and results.vsel.explicit); - try std.testing.expectEqualStrings("build.zig:1:1", selectedOutputText(results) orelse ""); - try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(results.file.?.output)); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 1), results.cur_row); - try std.testing.expectEqualStrings("mise.toml", selectedOutputText(results) orelse ""); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 3), results.cur_row); // row 2 is not look-able - try std.testing.expectEqualStrings("uucode_config.zig:7:1", selectedOutputText(results) orelse ""); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 0), results.cur_row); // ring seam - - p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(p.freeSlot() != panes_before); - try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig")); - try std.testing.expectEqual(output_pane.Grain.word, output_pane.grain(p.panes[p.active].?.file.?.output)); -} - -test "n/N resumes the result output whose Look moved focus away" { - if (platform == .web) return; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ - .sub_path = "look-owner.txt", - .data = "alpha target\nbeta target\ngamma target\n", - }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path}); - const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); - defer p.deinit(); - - try std.testing.expect(p.executeBuiltinLine(0, "Look target")); - const rid = p.panes[0].?.search_pane orelse return error.MissingResults; - const results = p.panes[rid].?; - const first = results.cur_row; - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(results.look_at != null); - try std.testing.expectEqual(results.serial, p.look_walk_owner orelse return error.MissingLookOwner); - - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(first + 1, results.cur_row); - p.update(.{ .key = .{ .cp = 'N' } }); - try std.testing.expectEqual(first, results.cur_row); - - // A later no-match answer has no position to resume and therefore cannot - // steal the provenance established by the Look above. - const owner = results.serial; - const dir = std.fs.path.dirname(path) orelse "."; - try output_pane.fillResults( - p, - 0, - dir, - .search, - "no-such-result", - try p.gpa.dupe(u8, ""), - null, - ); - try std.testing.expectEqual(owner, p.look_walk_owner.?); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(rid, p.active); - try std.testing.expectEqual(first + 1, results.cur_row); - - // Emptying the owner itself keeps its identity as the ring's starting - // point. Deleting it then leaves a stale serial, never an id that can bind - // to the unrelated pane subsequently allocated in the same slot. - try output_pane.fillResults(p, 0, dir, .search, "target", try p.gpa.dupe(u8, ""), null); - try std.testing.expectEqual(owner, p.look_walk_owner.?); - try std.testing.expect(p.executeBuiltinLine(rid, "Del")); - try std.testing.expect(p.paneBySerial(owner) == null); - const replacement = try p.newShell(rid, ""); - try std.testing.expect(replacement.serial != owner); - try std.testing.expect(p.paneBySerial(owner) == null); -} - -test "N exactly reverses n across output panes and the ring seam" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - const first = try walkFixture(p, p.active, paths.cwd, "one"); - p.update(.{ .key = .{ .cp = 'n', .alt = true } }); - const second = try walkFixture(p, p.active, paths.cwd, "two"); - try std.testing.expect(first != second); - - const Mark = struct { pane: usize, row: i32, col: i32 }; - const here = struct { - fn at(pp: *Pardes) Mark { - const pane = pp.panes[pp.active].?; - return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col }; - } - }.at; - p.update(.{ .key = .{ .cp = 'n' } }); - const base = here(p); - var trail: [12]Mark = undefined; - for (&trail) |*mark| { - p.update(.{ .key = .{ .cp = 'n' } }); - mark.* = here(p); - } - var i = trail.len; - while (i > 0) { - i -= 1; - p.update(.{ .key = .{ .cp = 'N' } }); - const want = if (i == 0) base else trail[i - 1]; - try std.testing.expectEqual(want, here(p)); - } - var saw_first = false; - var saw_second = false; - for (trail) |mark| { - saw_first = saw_first or mark.pane == first; - saw_second = saw_second or mark.pane == second; - } - try std.testing.expect(saw_first and saw_second); -} - -test "n/N selects command outputs by whole row" { - if (platform == .web) return; - var cwd_buf: [4096]u8 = undefined; - var path_buf: [4096]u8 = undefined; - const paths = try projectPaths(&cwd_buf, &path_buf); - const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); - - try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel")); - const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList; - const themes = p.panes[tid].?; - try std.testing.expect(output_pane.fileTraits(themes.file.?.output).commands); - p.update(.{ .key = .{ .cp = 'n' } }); - const row0 = std.mem.trimEnd(u8, modal.lineSlice(themes.file.?.content, 0), " \t\r"); - try std.testing.expectEqualStrings(row0, selectedOutputText(themes) orelse ""); - const theme_before = p.settings.theme; - p.update(.{ .key = .{ .cp = 'n' } }); - p.update(.{ .key = .{ .cp = Key.tab } }); - try std.testing.expect(p.settings.theme != theme_before); - - try std.testing.expect(p.executeBuiltinLine(0, "Look build.zig")); - p.update(.tick); - try std.testing.expect(p.active != 0); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(usize, 0), p.active); -} diff --git a/src/panel_animation.zig b/src/panel_animation.zig deleted file mode 100644 index a77e5a34..00000000 --- a/src/panel_animation.zig +++ /dev/null @@ -1,662 +0,0 @@ -//! Backend-neutral vocabulary and math for pane transitions. -//! -//! The core publishes plain transition data and composes semantic PanelAscii -//! bytes itself. GUI shells evaluate geometry/dissolve data in shaders; the -//! TTY shell evaluates the same records over cells in its grid. There are -//! deliberately no callbacks or backend objects here. -const std = @import("std"); - -pub const ascii_max_movement_frames: u16 = 12; - -pub const Easing = enum(u8) { - linear, - smooth, - /// Quintic ease-in-out. It creeps at both ends and crosses the middle of - /// the distance fast, inside the same frame count a linear walk would use. - smoother, - in_cubic, - out_cubic, - out_back, -}; - -pub const Transition = enum(u8) { - // These values cross both GUI shader ABIs. GLSL receives the enum in the - // instance uvec4 and the Core Image kernel receives it as a float, so - // spelling the numbers here keeps a source reorder from changing pixels. - off = 0, - slide = 1, - zoom = 2, - dissolve = 3, - ascii = 4, - vertical = 5, - // Character effects the core composes into Surface cells (see - // `composedByCore`). A backend never evaluates them: it receives finished - // glyphs, so these ids reach a shader only as "draw this panel batch". - edges = 6, - fall = 7, - wave = 8, - curtain = 9, - scramble = 10, - typewriter = 11, - - pub fn easing(effect: Transition) Easing { - return switch (effect) { - .off, .dissolve, .wave => .smooth, - .slide, .vertical, .edges => .out_cubic, - .zoom => .out_back, - // Character walks and per-cell locks read best with a slow start, - // a fast middle, and a slow settle over their fixed frame count. - .ascii, .fall, .scramble => .smoother, - // A sweep and a typewriter are constant-rate by definition: easing - // their head would make the pass visibly hesitate mid-pane. - .curtain, .typewriter => .linear, - }; - } - - pub fn frames(effect: Transition) u16 { - return switch (effect) { - .off => 0, - .slide => 12, - .zoom => 14, - .dissolve => 10, - // Frame zero is the exact old byte. Core's AsciiDiff caps a long - // byte walk at twelve eased movement samples, including the exact - // destination; nearby bytes still move one value at a time. - .ascii => ascii_max_movement_frames + 1, - .vertical => 12, - .edges, .curtain, .scramble => 12, - // Travelling motion needs a couple more samples than a lock or a - // rigid slide before it stops reading as a jump. - .fall, .wave, .typewriter => 14, - }; - } - - /// Character effects whose glyphs the core writes into the published - /// Surface. Every backend rasterizes the same finished cells, which is why - /// none of them owns a byte walk, a stagger, or a noise threshold. - pub fn composedByCore(effect: Transition) bool { - return switch (effect) { - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, - .off, .slide, .zoom, .dissolve, .vertical => false, - }; - } - - pub fn needsPreviousGrid(effect: Transition) bool { - return effect == .dissolve or effect == .vertical or effect.composedByCore(); - } - - pub fn lifecycleOnly(effect: Transition) bool { - return effect == .vertical; - } -}; - -/// Full-scene shader effects. CRT is one effect in this vocabulary rather -/// than a separate renderer switch; only one scene pass is needed even when -/// more than one bit is enabled. -pub const SceneEffect = struct { - crt: bool = false, - ripple: bool = false, - glitch: bool = false, -}; - -pub const Phase = enum(u8) { - opening = 0, - moving = 1, - /// Presentation-only content whose pane lifetime has already ended. - /// It is never a valid input target. - closing = 2, -}; - -pub const Box = extern struct { - x: f32 = 0, - y: f32 = 0, - w: f32 = 0, - h: f32 = 0, - - pub fn eql(a: Box, b: Box) bool { - return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; - } - - /// Whether a grid cell falls inside this box. Cells are whole, boxes are - /// fractional mid-animation, so the test is the cell's ORIGIN against a - /// half-open range: a box straddling a column owns it once its origin is - /// covered, and never owns it twice. - pub fn contains(box: Box, col: u16, row: u16) bool { - const x: f32 = @floatFromInt(col); - const y: f32 = @floatFromInt(row); - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } -}; - -/// The order every backend composites tracks in: moving panes first, then new -/// panes, then inert closing tombstones on top. Returns how many were written. -/// -/// A function rather than a loop inside `Pardes.render` because it is a RULE -/// three hosts used to re-derive — macos.zig re-sorted the already-sorted list -/// and tty/panel_compositor.zig walked the phases again — and a second sort -/// that happens to agree is the one that silently stops agreeing. `render` -/// calls this and every host receives the result verbatim. -/// -/// Inactive tracks are dropped here, so a host never has to ask. -pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { - var len: usize = 0; - for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { - const track = maybe orelse continue; - if (!track.active() or track.phase != phase) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - }; - for (closing) |track| { - if (!track.active()) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - } - return len; -} - -/// One POD record is enough for every backend. `from` and `to` are logical -/// cell boxes; frontends convert them to pixels only at their render edge. -pub const Track = extern struct { - serial: u32 = 0, - pane: u8 = 0, - phase: Phase = .moving, - effect: Transition = .off, - _padding: u8 = 0, - frame: u16 = 0, - /// Core-computed duration for data-dependent effects. Zero selects the - /// effect preset; PanelAscii fills this from the longest eased byte walk - /// in the pane's semantic cell diff. - frame_count: u16 = 0, - from: Box = .{}, - to: Box = .{}, - - pub fn active(track: Track) bool { - return track.effect != .off and track.frame < track.frames(); - } - - pub fn frames(track: Track) u16 { - return if (track.frame_count != 0) track.frame_count else track.effect.frames(); - } - - pub fn amount(track: Track) f32 { - // Opening rises quickly and settles; closing reverses that motion and - // accelerates down out of the fixed clip. - if (track.phase == .closing and track.effect == .vertical) - return progressEased(.in_cubic, track.frames(), track.frame); - return progressEased(track.effect.easing(), track.frames(), track.frame); - } - - pub fn presented(track: Track) Box { - return lerpBox(track.from, track.to, track.amount()); - } - - /// Geometry actually painted by every backend. Content transitions reveal - /// or move cells inside the final rectangle; slide, zoom, and vertical - /// transform the panel rectangle itself. - pub fn visualBox(track: Track) Box { - return switch (track.effect) { - .slide, .zoom, .vertical => track.presented(), - .off, .dissolve => track.to, - // Every character effect stays inside the pane's final rectangle. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, - }; - } - - /// The canonical box whose cells a backend samples. Opening and moving - /// tracks sample the new frame at `to`; a closing tombstone samples the - /// frozen old frame at `from` because its pane no longer exists. - pub fn contentBox(track: Track) Box { - return if (track.phase == .closing) track.from else track.to; - } -}; - -/// Preset starting geometry for a newly-visible panel. Dissolve and the -/// character effects animate content in place; slide, zoom, and vertical -/// animate its rectangle. -pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { - return switch (effect) { - .slide => blk: { - var from = target; - const middle = target.x + target.w * 0.5; - from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) - -target.w - else - @floatFromInt(screen_width); - break :blk from; - }, - .zoom => .{ - .x = target.x + target.w * 0.5, - .y = target.y + target.h * 0.5, - .w = 0, - .h = 0, - }, - // Slide upward into a fixed clip equal to the new pane's own box. - // Starting one panel-height below that box keeps the translated - // content from travelling across any surviving pane. - .vertical => blk: { - var from = target; - from.y += target.h; - break :blk from; - }, - .off, .dissolve => target, - // Character effects own the glyphs inside a fixed rectangle, so their - // panel opens at exactly its final geometry. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, - }; -} - -/// Destination for a lifecycle-only closing track. The old panel drops out -/// through its own fixed clip, reversing the opening path; surviving panes -/// are already at canonical geometry underneath it and never receive tracks -/// for this effect. -pub fn closingBox(effect: Transition, source: Box) Box { - return switch (effect) { - .vertical => blk: { - var to = source; - to.y += source.h; - break :blk to; - }, - else => source, - }; -} - -pub fn sample(easing: Easing, raw: f32) f32 { - const t = std.math.clamp(raw, 0.0, 1.0); - return switch (easing) { - .linear => t, - .smooth => t * t * (3.0 - 2.0 * t), - .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), - .in_cubic => t * t * t, - .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), - // Robert Penner's ease-out-back polynomial. It intentionally travels - // a little past one before settling exactly on the endpoint. - .out_back => blk: { - const c1: f32 = 1.70158; - const c3 = c1 + 1.0; - const u = t - 1.0; - break :blk 1.0 + c3 * u * u * u + c1 * u * u; - }, - }; -} - -pub fn progress(effect: Transition, frame: u16) f32 { - return progressEased(effect.easing(), effect.frames(), frame); -} - -fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { - if (frames <= 1 or frame >= frames - 1) return 1.0; - // `frames` is the number of presented samples, including both exact - // endpoints. This makes the last active frame the real final image rather - // than 15/16 followed by an unrendered snap to canonical content. - return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); -} - -pub fn lerpBox(from: Box, to: Box, t: f32) Box { - // Keep easing overshoot for opening/moving geometry—the whole visual - // distinction of out-back—while preventing any shrinking dimension from - // becoming negative and flipping its quad. - const u = @max(0.0, t); - return .{ - .x = from.x + (to.x - from.x) * u, - .y = from.y + (to.y - from.y) * u, - .w = @max(0.0, from.w + (to.w - from.w) * u), - .h = @max(0.0, from.h + (to.h - from.h) * u), - }; -} - -/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only -/// hashing means resizing or repainting a frame does not make cells flicker. -pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { - var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); - x ^= x >> 16; - x *%= 0x7feb_352d; - x ^= x >> 15; - x *%= 0x846c_a68b; - x ^= x >> 16; - return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; -} - -/// Whether a changed dissolve cell has crossed from the frozen old grid to -/// the new one. Exact endpoints are part of the presentation contract. -pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { - const t = std.math.clamp(raw_progress, 0.0, 1.0); - if (t <= 0) return false; - if (t >= 1) return true; - return cellNoise(serial, col, row) < t; -} - -/// The pane-local cell grid a core-composed character effect walks. Origin and -/// size use the same floor/ceil convention as the GUI cell-coordinate upload, -/// so the core's composition and any backend port index the same glyph. -pub const CellArea = struct { - x0: u16 = 0, - y0: u16 = 0, - cols: u16 = 1, - rows: u16 = 1, - - pub fn of(box: Box) CellArea { - return .{ - .x0 = floorCell(box.x), - .y0 = floorCell(box.y), - .cols = ceilCell(box.w), - .rows = ceilCell(box.h), - }; - } -}; - -fn floorCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); -} - -fn ceilCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); -} - -/// What one pane cell shows this frame under a core-composed character -/// effect. `at` offsets are in cells and relative to the destination cell, so -/// an all-zero offset is exactly the canonical glyph and every effect ends on -/// the untouched final frame. -pub const CharSource = union(enum) { - /// Nothing has arrived here yet: keep the frozen old cell. - old, - at: Offset, - /// Paint this printable byte in the destination cell's own style, whatever - /// that cell holds — a caret marching over empty space is still a caret. - byte: u8, - /// Paint this printable byte only where there is a glyph to churn. Noise - /// over blank cells would fill a pane with junk instead of letting its - /// text resolve out of noise. - churn: u8, - - pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; - - pub const settled: CharSource = .{ .at = .{} }; -}; - -/// One cell of one core-composed character effect. Offsets travel with the -/// glyph rather than blending it: a cell either holds a real glyph from the -/// new grid, the frozen old glyph, or a churning byte, never a mix. A source -/// outside the pane is the caller's cue to keep the old cell. -pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { - const t = track.amount(); - if (t >= 1.0) return .settled; - const w: f32 = @floatFromInt(area.cols); - const h: f32 = @floatFromInt(area.rows); - const c: f32 = @floatFromInt(col); - const r: f32 = @floatFromInt(row); - const remaining = 1.0 - t; - return switch (track.effect) { - // Whole rows arrive from the left and right screen edges, alternating. - // Sliding rigid rows is what keeps the glyphs crisp: one row is one - // rigid translation, so no cell ever samples two source glyphs. - .edges => blk: { - const travel = cellsOf(remaining * (w + 1.0)); - break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; - }, - // Columns rain down, each with its own stable head start, so the pane - // fills from the top and the last glyphs land at the bottom. - .fall => blk: { - const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); - if (local <= 0.0) break :blk .old; - break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; - }, - // A vertical ripple travels left to right and its amplitude decays, so - // the pane settles out of a wave instead of a fade. - .wave => .{ .at = .{ - .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), - } }, - // A curtain of glyphs marches in from the right, column by column, left - // to right; each column still has a short slide of its own. - .curtain => blk: { - const lead = t * (w + 1.0) - c; - if (lead <= 0.0) break :blk .old; - break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; - }, - // Every cell churns through printable ASCII and locks onto its final - // glyph at its own stable threshold: the pane resolves out of noise. - .scramble => blk: { - if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; - const churn = cellNoise( - track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), - col, - row, - ); - break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; - }, - // Reading-order reveal with a caret sitting on the write head. - .typewriter => blk: { - const head = t * w * h; - const index = r * w + c; - if (index + 1.0 <= head) break :blk .settled; - if (index <= head) break :blk .{ .byte = '_' }; - break :blk .old; - }, - // PanelAscii walks its own byte distance per cell, and the geometry - // effects never reach this path at all. - .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, - }; -} - -fn cellsOf(distance: f32) i32 { - return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); -} - -/// Remap track progress into one cell's own window. A stagger delays a glyph -/// without making the effect as a whole end after its last frame. -fn staggered(t: f32, delay: f32) f32 { - if (delay >= 1.0) return t; - return (t - delay) / (1.0 - delay); -} - -test "easing presets have exact endpoints and intended shapes" { - inline for (std.enums.values(Easing)) |easing| { - try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); - try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); - } - try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); - try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); - try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); - try std.testing.expect(sample(.out_back, 0.8) > 1.0); - // Slow at both ends, fast through the middle, and symmetric about the - // halfway point: the same curve the integer byte walk reproduces. - try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); - try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); - try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); - try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > - sample(.linear, 0.6) - sample(.linear, 0.4)); -} - -test "transition progress completes exactly" { - inline for (std.enums.values(Transition)) |effect| { - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); - if (effect.frames() > 0) - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); - try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); - } - try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); - - const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); - try std.testing.expectEqual(@as(f32, 0), shrinking.w); - try std.testing.expectEqual(@as(f32, 0), shrinking.h); - const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); - try std.testing.expect(opening.w > 100); - try std.testing.expect(opening.h > 40); -} - -test "character effects are core-composed and settle on the canonical glyph" { - const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; - const area: CellArea = .of(box); - try std.testing.expectEqual(@as(u16, 4), area.x0); - try std.testing.expectEqual(@as(u16, 2), area.y0); - try std.testing.expectEqual(@as(u16, 20), area.cols); - try std.testing.expectEqual(@as(u16, 6), area.rows); - - inline for (std.enums.values(Transition)) |effect| { - if (comptime !effect.composedByCore()) continue; - // Core composition needs the frozen old grid for every glyph which has - // not arrived, so no character effect may animate without it. - try std.testing.expect(effect.needsPreviousGrid()); - if (comptime effect == .ascii) continue; // owns its own per-cell byte walk - - const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; - const first: Track = .{ .effect = effect, .frame = 0, .to = box }; - var moving = false; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - var col: u16 = 0; - while (col < area.cols) : (col += 1) { - // The last active sample is the exact canonical grid: no cell - // is displaced, churning, or still frozen. - try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); - if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) - moving = true; - } - } - try std.testing.expect(moving); - } -} - -test "each character effect moves glyphs along its own axis" { - const box: Box = .{ .w = 30, .h = 8 }; - const area: CellArea = .of(box); - - // Rows alternate which screen edge they come from, and every glyph in a row - // travels as one rigid slide: one offset, no vertical component. - var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; - const even = charSource(edges, 5, 0, area).at; - const odd = charSource(edges, 5, 1, area).at; - try std.testing.expect(even.cols > 0); - try std.testing.expectEqual(-even.cols, odd.cols); - try std.testing.expectEqual(@as(i32, 0), even.rows); - try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); - edges.frame = 5; - try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); - - // Falling columns are vertical only, staggered, and sample from below the - // destination because the new text is still above the pane. - const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; - var falling = false; - var col: u16 = 0; - while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { - .old => {}, - .byte, .churn => return error.FallShouldNotChurn, - .at => |offset| { - try std.testing.expectEqual(@as(i32, 0), offset.cols); - try std.testing.expect(offset.rows >= 0); - if (offset.rows > 0) falling = true; - }, - }; - try std.testing.expect(falling); - - // The wave displaces rows both ways as it travels, and only rows. - const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; - var above = false; - var below = false; - col = 0; - while (col < area.cols) : (col += 1) { - const offset = charSource(wave, col, 3, area).at; - try std.testing.expectEqual(@as(i32, 0), offset.cols); - if (offset.rows < 0) above = true; - if (offset.rows > 0) below = true; - } - try std.testing.expect(above and below); - - // The curtain has a head: columns behind it hold the old grid, columns the - // head has passed are settled, and the head itself is still sliding. - const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); - try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); - var sliding = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { - .at => |offset| if (offset.cols < 0) { - sliding = true; - }, - .old, .byte, .churn => {}, - }; - try std.testing.expect(sliding); - - // Scramble churns printable ASCII per cell and per frame, then locks. It - // asks for churn rather than an unconditional byte, so the compositor can - // keep the pane's blank space blank. - var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; - var churning: usize = 0; - var locked: usize = 0; - var changed = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { - .churn => |byte| { - try std.testing.expect(byte >= ' ' and byte <= '~'); - churning += 1; - scramble.frame = 4; - switch (charSource(scramble, col, 0, area)) { - .churn => |next| changed = changed or next != byte, - .old, .at, .byte => {}, - } - scramble.frame = 3; - }, - .at => locked += 1, - .old, .byte => return error.ScrambleShouldNotFreeze, - }; - try std.testing.expect(churning > 0 and locked > 0 and changed); - - // The typewriter writes in reading order with a caret on its head. - const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); - try std.testing.expectEqual( - CharSource{ .old = {} }, - charSource(typewriter, area.cols - 1, area.rows - 1, area), - ); - var carets: usize = 0; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { - .byte => |byte| { - try std.testing.expectEqual(@as(u8, '_'), byte); - carets += 1; - }, - .old, .at, .churn => {}, - }; - } - try std.testing.expectEqual(@as(usize, 1), carets); -} - -test "opening presets separate geometry and content transitions" { - const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; - try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); - try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); - try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); - try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); - try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); - - var track: Track = .{ .effect = .slide, .from = target, .to = target }; - try std.testing.expect(track.active()); - track.frame = track.effect.frames(); - try std.testing.expect(!track.active()); - - track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; - try std.testing.expectEqual(target, track.visualBox()); - - var closing: Track = .{ - .phase = .closing, - .effect = .vertical, - .from = target, - .to = closingBox(.vertical, target), - }; - try std.testing.expectEqual(target, closing.contentBox()); - closing.frame = 2; - try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); -} - -test "dissolve has exact stable endpoints" { - for (0..64) |col| { - const x: u16 = @intCast(col); - try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); - try std.testing.expect(dissolveRevealed(42, x, 7, 1)); - if (dissolveRevealed(42, x, 7, 0.25)) - try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); - } -} diff --git a/src/panes.zig b/src/panes.zig new file mode 100644 index 00000000..6db7f53b --- /dev/null +++ b/src/panes.zig @@ -0,0 +1,7681 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const pardes = @import("pardes.zig"); +const layout = @import("layout.zig"); +const config = @import("config.zig"); +const Pardes = pardes.Pardes; +const modal = @import("modal.zig"); +const filesystem = @import("fs.zig"); +const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); +const dump = @import("dump.zig"); +const limits = @import("memory.zig").limits; +const builtins = @import("builtins.zig"); +const effect_sources = @import("effect_sources.zig"); +const build_options = @import("pardes_config"); +const lsp = @import("lsp/lsp.zig"); +const image = @import("image.zig"); +const look = @import("look.zig"); +const Key = pardes.Key; + +/// An owned editable buffer and the absolute surface row of its first line. +/// Files use row zero; terminal overlays may begin anywhere in scrollback. +pub const EditText = struct { text: []u8, row0: i32 }; + +pub const Pane = struct { + pub const Mode = enum { normal, insert, tty }; + + /// One mouse selection (block-shaped), per button. c/r are text-area relative; + /// r counts from the tag row (body starts at BOX_H). + pub const Sel = struct { + state: enum { none, dragging, done } = .none, + c0: i32 = 0, + c1: i32 = 0, + r0: i32 = 0, + r1: i32 = 0, + }; + + /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. + pub const LineSel = struct { + active: bool = false, + r0: i32 = 0, + r1: i32 = 0, + }; + + pub const CharSel = struct { + active: bool = false, + row: i32 = 0, + col: i32 = 0, + explicit: bool = false, + }; + + pub const LookSpot = struct { + row: i32, + col0: i32, + col1: i32, + }; + + pub const max_selections = 64; + + pub const SelRange = struct { + row: i32, + col: i32, + arow: i32, + acol: i32, + /// this range's own j/k goal column (helix Range::old_visual_position); + /// the PRIMARY's copy is Pane.sticky_col + sticky: i32 = -1, + }; + + const PdfSlot = if (Pdf.enabled) ?Pdf.State else void; + + pub const Prompt = union(enum) { + none, + search: u16, + pipe: struct { at: u16, how: modal.Normal.PipeBehavior }, + /// Save on a scratch buffer or a terminal: the tail is a path to write to. + save: u16, + }; + + pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; + terminal: ?*Terminal.State = null, + gpa: std.mem.Allocator, + serial: u32 = 0, + mode: Mode = .normal, + vweight: f32 = 1, + cols: u16, + rows: u16, + greet: bool = false, + pending_command: Terminal.PendingCommand = .{}, + file: ?File.State = null, + image: ?Image.State = null, + pdf: PdfSlot = if (Pdf.enabled) null else {}, + msel: LineSel = .{}, + vsel: CharSel = .{}, + sels: [max_selections - 1]SelRange = undefined, + nsel: u8 = 0, + select: bool = false, + /// sticky goal column for j/k runs (helix old_visual_position): any + /// non-vertical range write resets it to -1. + sticky_col: i32 = -1, + append_at: ?struct { row: i32, col: i32 } = null, + normal: modal.Normal.State = .{}, + /// last f/F/t/T motion, for Alt-. repeat + find_op: u8 = 0, + find_ch: u21 = 0, + /// Tag-tail input state. The tag text is presentation; this tag carries + /// which operation owns it and the tail offset restored on submit/cancel. + prompt: Prompt = .none, + search_pane: ?usize = null, + search_row: ?usize = null, + look_at: ?LookSpot = null, + sel_snap: [max_selections]modal.Selection = undefined, + nsel_snap: u8 = 0, + sel_snap_pri: u8 = 0, + sel_snap_expl: bool = false, + /// the editable tag tail: a bounded one-line command buffer. Input that + /// does not fit is refused atomically. + tag_tail: [limits.max_tag_tail]u8 = undefined, + tag_tail_len: usize = 0, + tag_init: bool = false, + tag_edit: bool = false, + tag_sel: bool = false, + /// the body mode a tag edit hijacked (tags are always insert); terminals + /// restore it on exit so clicking the tag never changes the pane's mode + tag_mode: Mode = .normal, + tag_col: u16 = 0, + tag_anchor: u16 = 0, + ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, + ed_undo_len: usize = 0, + ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, + ed_redo_len: usize = 0, + cwd: Cwd = .none, + cur_pinned: bool = false, + cur_row: i32 = 0, + cur_col: i32 = 0, + hscroll: i32 = 0, + // Visible rows map to source lines and raw/display column origins. + wrap_line: [limits.wrap_rows]i32 = undefined, + wrap_col: [limits.wrap_rows]i32 = undefined, + wrap_n: u16 = 0, + sel: [3]Sel = @splat(.{}), + /// terminals only: the typed-text buffer standing in for shell rows + ovl: ?Terminal.EditBuffer = null, + tty_filter: bool = false, + msg: [256]u8 = undefined, + msg_len: u16 = 0, + + pub fn tagSlice(p: *const Pane) []const u8 { + return p.tag_tail[0..p.tag_tail_len]; + } + pub fn promptAt(p: *const Pane) ?u16 { + return switch (p.prompt) { + .none => null, + .search, .save => |at| at, + .pipe => |pipe| pipe.at, + }; + } + + pub fn appendTag(p: *Pane, text: []const u8) bool { + if (text.len > p.tag_tail.len - p.tag_tail_len) return false; + @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); + p.tag_tail_len += text.len; + return true; + } + + pub fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { + if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; + std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); + p.tag_tail[at] = byte; + p.tag_tail_len += 1; + return true; + } + + pub fn removeTagByte(p: *Pane, at: usize) void { + if (at >= p.tag_tail_len) return; + std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); + p.tag_tail_len -= 1; + } + + pub fn cwdSlice(p: *const Pane) []const u8 { + return switch (p.cwd) { + .none => "", + .owned => |dir| dir, + .inherited => |src| src.cwdSlice(), + }; + } + + pub fn clearCwd(pane: *Pane) void { + if (pane.cwd == .owned) pane.gpa.free(pane.cwd.owned); + pane.cwd = .none; + } + + pub fn setOwnedCwd(pane: *Pane, dir: []const u8) !void { + if (dir.len > limits.host_path_cap) return error.PathTooLong; + const copy = try pane.gpa.dupe(u8, dir); + pane.clearCwd(); + pane.cwd = .{ .owned = copy }; + } + + pub fn isTerminal(pane: *const Pane) bool { + const no_pdf = if (comptime Pdf.enabled) pane.pdf == null else true; + return pane.file == null and pane.image == null and no_pdf; + } + + /// The one coloring choice keyed on what a pane IS, so the highlight + /// producer (refreshHighlights) and the render pass agree on the algorithm. + pub const ColorAlgo = enum { none, tty, source, diff, locations }; + pub fn colorAlgo(pane: *const Pane) ColorAlgo { + if (pane.isTerminal()) return .tty; + if (pane.file) |f| { + if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; + if (f.output != null and !Output.fileTraits(f.output).saves) return .locations; + return .source; + } + return .none; + } + + pub fn pdfPath(pane: *const Pane) ?[]const u8 { + if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.path; + return null; + } + + pub fn pdfPage(pane: *const Pane) ?usize { + if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.page; + return null; + } + + pub fn surfRow(pane: *const Pane, g: i32) i32 { + const o = pane.ovl orelse return g; + if (g <= o.row) return g; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (g >= o.row + o.rows) return g + lines - o.rows; + return @min(g, o.row + lines - 1); // inside the buffer: its own rows + } + + /// the inverse; every surface row inside the edit buffer maps to its anchor + pub fn gridRow(pane: *const Pane, s: i32) i32 { + const o = pane.ovl orelse return s; + if (s <= o.row) return s; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (s < o.row + lines) return o.row; + return s - lines + o.rows; + } + + /// current scroll offset: file top line, or the scrollback offset + 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)); + } + + pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { + if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) + return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; + // unwrapped: rows ARE lines, and the byte column a row starts at is the + // horizontal scroll (always 0 on a terminal, which never has one) + return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; + } + + pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { + if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; + var i: u16 = 0; + while (i < pane.wrap_n) : (i += 1) { + if (pane.wrap_line[i] != line) continue; + // the LAST row of a line owns every column past its start, so a + // cursor parked on the trailing newline still has somewhere to draw + if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; + return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; + } + return .{ .row = -1, .at = 0 }; + } + + pub fn scrollBy(pane: *Pane, delta: i32) void { + if (pane.file) |*f| { + const max: i64 = @intCast(File.nlines(pane.gpa, f) -| 1); + const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); + const next: usize = @intCast(n); + if (next != f.scroll) { + f.scroll = next; + f.syntax_dirty = true; + } + } else if (hasPdf(pane)) { + if (comptime Pdf.enabled) { + const pv = &pane.pdf.?; + const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); + pv.text_scroll = @intCast(std.math.clamp( + @as(i64, @intCast(pv.text_scroll)) + delta, + 0, + max, + )); + } + } 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); + } + } + + pub fn ensureCursorVisible(pane: *Pane) void { + // scrolloff margin, shrunk on short panes so the band stays non-empty + var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + const off = pane.scroll(); + var last = off + @as(i32, pane.rows) - 1; + if (pane.wrap_n > 0) { + const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0]; + last = off + lines_shown; + margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); + } + if (pane.cur_row < off + margin) { + pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 + } else if (pane.cur_row > last - margin) { + // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); + // terminals overshoot harmlessly — the vt clamps at the live bottom + var to = pane.cur_row + margin; + if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1))); + if (comptime Pdf.enabled) { + if (pane.pdf) |pv| + to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); + } + pane.scrollBy(@max(0, to - last)); + } + if (pane.file) |f| { + if (pane.wrap_n != 0) return; + const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane))); + const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); + const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); + const raw_cur: usize = @intCast(@max(0, pane.cur_col)); + const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); + const cur = @as(i32, @intCast(File.rawDisplayCol(line, raw_cur))); + const visual_scroll = @as(i32, @intCast(File.rawDisplayCol(line, raw_scroll))); + var target = visual_scroll; + if (cur < visual_scroll + hmargin) + target = @max(0, cur - hmargin) + else if (cur > visual_scroll + w - 1 - hmargin) + target = cur - (w - 1 - hmargin); + if (target != visual_scroll) pane.hscroll = @intCast(File.rawAtDisplay(line, @intCast(target))); + } + } + + pub fn pinCursor(pane: *Pane) void { + if (pane.cur_pinned) return; + if (pane.file != null or hasPdf(pane)) { + pane.cur_row = pane.scroll(); + pane.cur_col = 0; + } else { + const cur = Terminal.gridCursor(pane); + pane.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); + pane.cur_col = @intCast(cur.x); + } + pane.cur_pinned = true; + } + + pub fn hasPdf(pane: *const Pane) bool { + return if (comptime Pdf.enabled) pane.pdf != null else false; + } + + pub fn hasPdfSelection(pane: *const Pane) bool { + return if (comptime Pdf.enabled) + if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false + else + false; + } + + pub fn toModalCursor(pane: *Pane) modal.Cursor { + return .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + } + + pub fn fromModalCursor(pane: *Pane, c: modal.Cursor) void { + pane.cur_row = @as(i32, @intCast(c.row)); + pane.cur_col = @intCast(c.col); + pane.cur_pinned = true; + } + + pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { + if (lines.len == 0) return c; + const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; + const target = lines[row]; + if (target.len == 0) return .{ .row = row, .col = 0 }; + const source = if (c.row < lines.len) lines[c.row] else ""; + const goal = File.rawDisplayCol(source, c.col); + const mapped = File.rawAtDisplay(target, goal); + const last = modal.prevGrapheme(target, target.len); + return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; + } + + pub fn primaryRange(pane: *Pane, text: []const u8, row0: i32) modal.Selection { + const c = File.textOffset(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); + if (pane.msel.active) { + // legacy line selection (file-search results highlight): linewise + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); + const s = modal.lineStartOffset(text, r0); + const e = if (r1 + 1 >= modal.cursorLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); + return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; + } + if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); + return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; + } + + pub fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.Selection { + const a = modal.offsetAt(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); + const c = modal.offsetAt(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); + return cellOffRange(text, a, c); + } + + /// the same, from the two cells' gap offsets + pub fn cellOffRange(text: []const u8, a: usize, c: usize) modal.Selection { + if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; + return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; + } + + pub fn rangeCells(text: []const u8, r: modal.Selection) struct { cur: usize, anc: usize } { + if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; + if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; + return .{ .cur = r.head, .anc = r.head }; + } + + pub fn setRange(pane: *Pane, text: []const u8, row0: i32, r0: modal.Selection, explicit: bool) void { + var r = r0; + if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 + const off = rangeCells(text, r); + const cc = File.textPosition(pane, text, off.cur); + // a bare block cursor has both cells on the same offset — the common + // case by far — and this conversion is not free even indexed + const ac = if (off.anc == off.cur) cc else File.textPosition(pane, text, off.anc); + pane.cur_row = @as(i32, @intCast(cc.row)) + row0; + pane.cur_col = @intCast(cc.col); + pane.vsel = .{ + .active = off.anc != off.cur or pane.select, + .row = @as(i32, @intCast(ac.row)) + row0, + .col = @intCast(ac.col), + .explicit = explicit or pane.select, + }; + pane.msel.active = false; + pane.nsel = 0; // writing ONE range means the selection IS that range + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.normal.clear(); + pane.ensureCursorVisible(); + } + + pub fn ranges(pane: *Pane, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } { + const pr = primaryRange(pane, text, row0); + var n: usize = 0; + var pri: usize = 0; + var placed = false; + for (pane.sels[0..pane.nsel]) |s| { + const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); + if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { + pri = n; + out[n] = pr; + n += 1; + placed = true; + } + out[n] = r; + n += 1; + } + if (!placed) { + pri = n; + out[n] = pr; + n += 1; + } + return .{ .n = n, .pri = pri }; + } + + pub fn setRanges(pane: *Pane, text: []const u8, in: []const modal.Selection, sticky: []const i32, pri0: usize, explicit: bool) void { + if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" + var r: [max_selections]modal.Selection = undefined; + var st: [max_selections]i32 = undefined; + var n: usize = @min(in.len, max_selections); + var pri: usize = @min(pri0, n - 1); + for (in[0..n], 0..) |x, i| { + r[i] = x; + if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); + st[i] = if (i < sticky.len) sticky[i] else -1; + } + // insertion sort by start — n is tiny and usually already ordered + var i: usize = 1; + while (i < n) : (i += 1) { + var j = i; + while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { + std.mem.swap(modal.Selection, &r[j], &r[j - 1]); + std.mem.swap(i32, &st[j], &st[j - 1]); + if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; + } + } + var k: usize = 0; + i = 1; + while (i < n) : (i += 1) { + const a = r[k]; + const b = r[i]; + const af = @min(a.anchor, a.head); + const at = @max(a.anchor, a.head); + const bf = @min(b.anchor, b.head); + const bt = @max(b.anchor, b.head); + if (af == bf or (at > bf and bt > af)) { + r[k] = if (a.anchor > a.head and b.anchor > b.head) + .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } + else + .{ .anchor = @min(af, bf), .head = @max(at, bt) }; + if (pri == i) pri = k; + if (st[k] < 0) st[k] = st[i]; + continue; + } + k += 1; + r[k] = b; + st[k] = st[i]; + if (pri == i) pri = k; + } + n = k + 1; + setRange(pane, text, 0, r[pri], explicit); + pane.sticky_col = st[pri]; + var w: usize = 0; + for (r[0..n], 0..) |x, idx| { + if (idx == pri) continue; + const c = rangeCells(text, x); + const cc = modal.positionAt(text, c.cur); + const ac = modal.positionAt(text, c.anc); + pane.sels[w] = .{ + .row = @as(i32, @intCast(cc.row)), + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)), + .acol = @intCast(ac.col), + .sticky = st[idx], + }; + w += 1; + } + pane.nsel = @intCast(w); + } + + pub fn multiSelAction(pane: *Pane, text: []const u8, kind: modal.Normal.Multi, cnt: usize) void { + var rs: [max_selections]modal.Selection = undefined; + const got = ranges(pane, text, 0, &rs); + const n = got.n; + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + if (kind == .remove_primary) { + if (n < 2) return; // helix: "no selections remaining" + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (i == got.pri) continue; + out[m] = r; + m += 1; + } + // helix Selection::remove: the NEXT range takes over, or the + // previous one when the primary was last + return setRanges(pane, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); + } + if (kind == .rotate_forward or kind == .rotate_backward) { + const step = cnt % n; + const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; + return setRanges(pane, text, rs[0..n], &.{}, pri, expl); + } + if (kind == .merge) { + // helix merge_selections: first.merge(last) — the ranges are + // sorted, so that is simply the whole span + const lo = @min(rs[0].anchor, rs[0].head); + const hi = @max(rs[n - 1].anchor, rs[n - 1].head); + const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; + const one: modal.Selection = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; + return setRanges(pane, text, &.{one}, &.{}, 0, expl); + } + if (kind == .merge_consecutive) { + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { + const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); + const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); + out[m - 1] = .{ .anchor = lo, .head = hi }; + if (i == got.pri) pri = m - 1; + continue; + } + if (i == got.pri) pri = m; + out[m] = r; + m += 1; + } + return setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + if (kind == .split_newline) { + // helix selection::split_on_newline — one range per line the + // selection covers, the newlines themselves left out + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + if (from == to) { + if (m < max_selections) { + out[m] = r; + m += 1; + } + continue; + } + var start = from; + while (start < to and m < max_selections) { + const eol = modal.lineEndOffset(text, modal.lineAtOffset(text, start)); + if (eol >= to) { + out[m] = .{ .anchor = start, .head = to }; + m += 1; + break; + } + out[m] = .{ .anchor = start, .head = eol }; + m += 1; + start = eol + 1; + } + } + if (m == 0) return; + return setRanges(pane, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 + } + if (kind == .trim) { + // helix trim_selections: whitespace off both ends; ranges that are + // empty or all whitespace are dropped entirely + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + var from = @min(r.anchor, r.head); + var to = @max(r.anchor, r.head); + while (from < to and std.ascii.isWhitespace(text[from])) from += 1; + while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; + if (from >= to) continue; + out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; + m += 1; + } + if (m == 0) { // helix: collapse_selection + keep_primary_selection + const c = modal.selectionCursor(text, rs[got.pri]); + return setRange(pane, text, 0, .{ .anchor = c, .head = c }, false); + } + // helix: the first survivor that OVERLAPS the old primary, else the last + const pf = @min(rs[got.pri].anchor, rs[got.pri].head); + const pt = @max(rs[got.pri].anchor, rs[got.pri].head); + var pri = m - 1; + for (out[0..m], 0..) |r, i| { + const f = @min(r.anchor, r.head); + const t = @max(r.anchor, r.head); + if (f == pf or (t > pf and pt > f)) { + pri = i; + break; + } + } + return setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + const below = kind == .copy_below; + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: usize = 0; + const nlines = modal.cursorLineCount(text); + for (rs[0..n], 0..) |r, ri| { + const is_pri = ri == got.pri; + // head-exclusive: back the leading end off onto its own cell + const hp = modal.positionAt(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); + const ap = modal.positionAt(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); + const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; + if (m >= max_selections) break; + if (is_pri) pri = m; + out[m] = r; + m += 1; + var made: usize = 0; + var k: usize = 0; + while (made < cnt and m < max_selections) : (k += 1) { + const d = (k + 1) * height; + const arow = if (below) ap.row + d else ap.row -| d; + const hrow = if (below) hp.row + d else hp.row -| d; + if (arow >= nlines or hrow >= nlines) break; + const a2 = modal.offsetAt(text, .{ .row = arow, .col = ap.col }); + const h2 = modal.offsetAt(text, .{ .row = hrow, .col = hp.col }); + // a line too short to reach the column is skipped, not clamped + if (modal.positionAt(text, a2).col == ap.col and modal.positionAt(text, h2).col == hp.col) { + if (is_pri) pri = m; + out[m] = modal.moveSelectionCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); + m += 1; + made += 1; + } + if (arow == 0 and hrow == 0) break; + } + } + setRanges(pane, text, out[0..m], &.{}, pri, expl); + } + + /// a range's start CELL (document order key) — the smaller of its two ends + pub fn selStart(s: SelRange) struct { row: i32, col: i32 } { + if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; + return .{ .row = s.row, .col = s.col }; + } + + pub fn maxLine(text: []const u8) usize { + const nl = modal.cursorLineCount(text); + return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; + } + + /// point-target motion: collapse there (extend in select mode) + pub fn pointMove(pane: *Pane, text: []const u8, range: modal.Selection, target: usize) void { + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, target, pane.select), false); + } + + /// word motions select their traversed span (extend mode: head only) + pub fn wordMove(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize, target: modal.WordTarget) void { + const wr = modal.moveWord(text, range, cnt, target); + const res = if (pane.select) modal.moveSelectionCursor(text, range, modal.selectionCursor(text, wr), true) else wr; + setRange(pane, text, 0, res, false); + } + + /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) + pub fn findMove(pane: *Pane, text: []const u8, range: modal.Selection, ch: u21, fwd: bool, till: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const t = modal.findTarget(text, cur, ch, fwd, till, cnt) orelse return; + const res = if (pane.select) + modal.moveSelectionCursor(text, range, t, true) + else + modal.moveSelectionCursor(text, .{ .anchor = cur, .head = cur }, t, true); + setRange(pane, text, 0, res, false); + } + + /// j/k and friends: sticky goal column, clamped onto short lines' newline + pub fn verticalMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(pane, text, cur); + const goal: usize = if (pane.sticky_col >= 0) + @intCast(pane.sticky_col) + else + File.rawDisplayCol(File.textLine(pane, text, pos.row), pos.col); + const last_row = File.textLineCount(pane, text) - 1; + const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); + const target_col = File.rawAtDisplay(File.textLine(pane, text, nline), goal); + const t = File.textOffset(pane, text, .{ .row = nline, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); + pane.sticky_col = @intCast(goal); + } + + pub fn visualMove( + pane: *Pane, + text: []const u8, + range: modal.Selection, + down: bool, + cnt: usize, + width: usize, + ) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(pane, text, cur); + const last_row = File.textLineCount(pane, text) - 1; + var row = pos.row; + var line = modal.lineSlice(text, row); + var vrow = File.visualRow(line, pos.col, width); + const goal: usize = if (pane.sticky_col >= 0) + @intCast(pane.sticky_col) + else + File.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); + var steps = @max(1, cnt); + while (steps > 0) : (steps -= 1) { + if (down) { + if (vrow.end < line.len) { + vrow = File.visualRow(line, vrow.end, width); + continue; + } + if (row == last_row) break; + row += 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, 0, width); + } else { + if (vrow.start > 0) { + vrow = File.visualRow(line, vrow.start - 1, width); + continue; + } + if (row == 0) break; + row -= 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, line.len, width); + } + } + // The newline slot is a real cursor position, but the first byte of the + // NEXT visual row is not: landing there would read as two rows moved. + var target_col = vrow.start + File.rawAtDisplay(line[vrow.start..vrow.end], goal); + if (vrow.end < line.len and target_col >= vrow.end) + target_col = modal.graphemeStart(line, vrow.end - 1); + const t = File.textOffset(pane, text, .{ .row = row, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); + pane.sticky_col = @intCast(goal); + } + + /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows + pub fn halfPageMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool) void { + const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); + pane.scrollBy(if (down) half else -half); + verticalMove(pane, text, range, down, @intCast(half)); + } + + /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift + /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 + pub fn scrollViewMove(pane: *Pane, text: []const u8, range: modal.Selection, delta: i32) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + pane.scrollBy(delta); + const top = pane.scroll(); + const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1); + const cur = modal.selectionCursor(text, range); + if (delta > 0) { + const snap: i32 = @max(0, @min(top + margin, last_row)); + const head = File.textLineStart(pane, text, @intCast(snap)); + if (head <= cur) return; + const anchor = if (pane.select) range.anchor else head; + setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); + } else { + const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); + const head = File.textLineStart(pane, text, @intCast(snap)); + if (head >= cur) return; + const anchor = if (pane.select) range.anchor else head; + setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); + } + } + + /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) + pub fn gotoWindow(pane: *Pane, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); + const top = pane.scroll(); + const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1); + const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); + const n: i32 = @intCast(cnt - 1); + var vline: i32 = switch (which) { + .top => top + margin + n, + .center => top + @divTrunc(last_vis, 2), + .bottom => top + last_vis - (margin + n), + }; + vline = @max(vline, top + margin); + vline = @min(vline, top + last_vis - margin); + const row: i32 = std.math.clamp(vline, 0, last_row); + pointMove(pane, text, range, File.textLineStart(pane, text, @intCast(row))); + } + + /// helix Range::line_range — the inclusive line span a range covers + pub fn rangeLineSpan(text: []const u8, r: modal.Selection) struct { start: usize, end: usize } { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); + return .{ .start = modal.lineAtOffset(text, from), .end = modal.lineAtOffset(text, to_adj) }; + } + + fn lineStartOrEof(text: []const u8, line: usize) usize { + if (line >= modal.cursorLineCount(text)) return text.len; + return modal.lineStartOffset(text, line); + } + + /// helix `x` extend_line_below: full lines incl. the newline, cursor ON + /// the last one's '\n'; an already-line-bounded selection grows downward + pub fn lineSelect(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; + const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); + setRange(pane, text, 0, .{ .anchor = start, .head = head }, true); + } + + /// helix `X` extend_to_line_bounds (direction kept) + pub fn lineBoundsSelect(pane: *Pane, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(pane, text, 0, r, true); + } + + /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) + pub fn shrinkSelToLineBounds(pane: *Pane, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + if (span.start == span.end) return; + const from = @min(range.anchor, range.head); + const to = @max(range.anchor, range.head); + var start = modal.lineStartOffset(text, span.start); + var end = lineStartOrEof(text, span.end + 1); + if (start != from) start = lineStartOrEof(text, span.start + 1); + if (end != to) end = modal.lineStartOffset(text, span.end); + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(pane, text, 0, r, expl); + } + + /// pull the cursor back inside `text` after a rewrite; `row0` is the + /// absolute surface row of its first line (0 for a file) + pub fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { + const n = modal.lineCount(text); + const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(text, row).len; + pane.cur_row = @as(i32, @intCast(row)) + row0; + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); + pane.cur_pinned = true; + pane.vsel.active = false; + pane.msel.active = false; + pane.ensureCursorVisible(); + } +}; + +pub const File = struct { + const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; + + /// Content and primary selection at one file edit boundary. Keeping only the + /// primary avoids putting Pane.max_selections ranges in every history entry. + pub const Snapshot = struct { + content: []u8, + cur_row: i32, + cur_col: i32, + vsel: Pane.CharSel, + }; + + pub const History = struct { + undo: [limits.undo_max]Snapshot = undefined, + undo_len: usize = 0, + redo: [limits.undo_max]Snapshot = undefined, + redo_len: usize = 0, + + pub fn create(gpa: std.mem.Allocator) !*History { + const history = try gpa.create(History); + history.undo_len = 0; + history.redo_len = 0; + return history; + } + }; + + /// A file pane's backing: owned content, its derived caches, and undo history. + pub const State = struct { + path: []u8, + content: []u8, + revision: u32 = 0, + /// Revision last known to match disk, after Save or an external reload. + /// Equal means the screen matches disk. + saved_revision: u32 = 0, + watch_after_save: bool = false, + /// Non-null for a generated output buffer rather than an on-disk file. + output: ?Output.State = null, + mini: ?Mini.State = null, + scroll: usize = 0, + /// `line_starts[i]` is line i's byte offset. Empty means not built yet. + line_starts: []usize = &.{}, + /// One tree_sitter_gpa-owned syntax.Syn byte per highlighted source byte. + highlights: []u8 = &.{}, + highlight_start: usize = 0, + syntax_dirty: bool = true, + history: *History, + }; + + pub fn dumpPane( + arena: std.mem.Allocator, + pane: *const Pane, + file: *const State, + tag: []const u8, + body: []const u8, + scroll: usize, + origin: []const u8, + origin_arg: []const u8, + ) !dump.Pane { + return .{ + .kind = .file, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .file = .{ + .path = file.path, + .content = file.content, + .content_b64 = try dump.encodeBytes(arena, file.content), + .dirty = file.revision != file.saved_revision, + .origin = origin, + .origin_arg = origin_arg, + .mini_source = if (file.mini) |mini| mini.source else "", + .mini_colors_b64 = if (file.mini) |mini| try dump.encodeBytes(arena, mini.colors) else "", + }, + }; + } + + pub fn graphemeDisplayWidth(grapheme: []const u8) usize { + if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; + return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + } + + pub fn byteDisplayWidth(byte: u8) usize { + return if (byte == '\t') config.tab_width else 1; + } + + pub fn displayWidth(text: []const u8) usize { + var width: usize = 0; + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + width +|= graphemeDisplayWidth(text[at..end]); + at = end; + } + return width; + } + + /// Source byte at a zero-based display column. Every cell occupied by a tab + /// maps back to that one tab byte. + pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { + var col: usize = 0; + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + const next = col +| graphemeDisplayWidth(text[at..end]); + if (display_col < next) return at; + col = next; + at = end; + } + return text.len; + } + + /// File cursor columns may live past EOL. Tabs expand before that boundary; + /// every virtual column after it remains one screen cell. + pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize { + const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len)); + return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len); + } + + pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { + const width = displayWidth(line_text); + if (display_col > width) return line_text.len +| (display_col - width); + return byteAtDisplay(line_text, display_col); + } + + pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize { + if (from_raw >= line_text.len) return from_raw +| display_col; + const from = modal.graphemeStart(line_text, from_raw); + return from +| rawAtDisplay(line_text[from..], display_col); + } + + pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { + const from_display = rawDisplayCol(line_text, from_raw); + const to_display = rawDisplayCol(line_text, to_raw); + if (to_display >= from_display) return @intCast(to_display - from_display); + return -@as(i32, @intCast(from_display - to_display)); + } + + pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { + const start = lineDisplayOffset(line_text, from_raw, at_raw); + if (at_raw >= line_text.len) return start; + const at = modal.graphemeStart(line_text, at_raw); + const end = modal.nextGrapheme(line_text, at); + return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1; + } + + pub fn sourceLine(pane: *const Pane, row: i32) []const u8 { + const f = if (pane.file) |*file| file else return ""; + if (row < 0) return ""; + const line: usize = @intCast(row); + if (f.line_starts.len == 0) return modal.lineSlice(f.content, line); + if (line >= f.line_starts.len) return ""; + const start = f.line_starts[line]; + const end = if (line + 1 < f.line_starts.len) f.line_starts[line + 1] - 1 else f.content.len; + return f.content[start..end]; + } + + test "indexed source rows match uncached scans across content changes" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile(""); + const file = &pane.file.?; + for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| { + setContent(p, file, try p.gpa.dupe(u8, content)); + try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + const rows = lineCount(content) + 2; + for ([_]bool{ false, true }) |indexed| { + if (indexed) _ = try lineIndex(p.gpa, file); + try std.testing.expectEqualStrings("", sourceLine(pane, -1)); + for (0..rows) |row| try std.testing.expectEqualStrings( + modal.lineSlice(content, row), + sourceLine(pane, @intCast(row)), + ); + if (!indexed) try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + } + } + } + + pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 { + const line_text = sourceLine(pane, row); + const from: usize = @intCast(@max(0, from_raw)); + const to: usize = @intCast(@max(0, to_raw)); + return lineDisplayOffset(line_text, from, to); + } + + pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { + const line_text = sourceLine(pane, row); + return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw))); + } + + pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { + const line_text = sourceLine(pane, row); + return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)))); + } + + pub fn gutterWidth(pane: *const Pane) u16 { + if (pane.file == null) return 0; + const file = &pane.file.?; + var lines = if (file.line_starts.len > 0) file.line_starts.len else lineCount(file.content); + var digits: u16 = 1; + while (lines >= 10) : (lines /= 10) digits += 1; + return @max(config.PREFIX_W, digits + 1); + } + + /// Convert between rendered cells and UTF-8 byte columns. Tag rows always + /// need grapheme conversion; file body rows additionally skip the gutter. + pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { + if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); + if (pane.file == null) return rawAtDisplay(line_text, display_col); + const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); + if (display_col <= prefix) return display_col; + return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix); + } + + pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { + if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); + if (pane.file == null) return rawDisplayCol(line_text, byte_col); + const prefix = @min(@as(usize, gutterWidth(pane)), line_text.len); + if (byte_col <= prefix) return byte_col; + return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix); + } + + test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" { + const ref = struct { + fn width(grapheme: []const u8) usize { + if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); + } + }.width; + + var one: [1]u8 = undefined; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + one[0] = b; + try std.testing.expectEqual(ref(one[0..1]), graphemeDisplayWidth(one[0..1])); + } + for ([_][]const u8{ + "e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}", + "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", + }) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g)); + } + + test "the ASCII run in fitEnd survives an exhaustive byte sweep" { + const reference = struct { + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + }.fitEnd; + + const neighbours = [_][]const u8{ + "", "z", "\u{301}", "\u{200d}\u{1f680}", + "\u{903}", "\u{fe0f}", "\u{4e16}", "\u{1f642}", + "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", + }; + var buf: [16]u8 = undefined; + // The same pair again behind an ASCII prefix, so a break can land exactly at the run boundary + // as well as before it and inside the multi-byte cluster that follows it. + var prefixed: [18]u8 = undefined; + prefixed[0] = 'a'; + prefixed[1] = 'b'; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + buf[0] = b; + for (neighbours) |tail| { + @memcpy(buf[1..][0..tail.len], tail); + const pair = buf[0 .. 1 + tail.len]; + @memcpy(prefixed[2..][0..pair.len], pair); + for ([_][]const u8{ pair, prefixed[0 .. 2 + pair.len] }) |text| { + var width: usize = 0; + while (width <= text.len + 3) : (width += 1) { + var start: usize = 0; + while (start <= text.len) : (start += 1) { + std.testing.expectEqual( + reference(text, start, width), + fitEnd(text, start, width), + ) catch |e| { + std.debug.print("fitEnd({any}, {d}, {d})\n", .{ text, start, width }); + return e; + }; + } + } + } + } + } + } + + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (used < width and end < text.len) { + const b = text[end]; + if (b < 0x20 or b >= 0x7f) break; + if (end + 1 < text.len and text[end + 1] >= 0x80) break; + used += 1; + end += 1; + } + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + + test "the ASCII run in fitEnd cuts where the grapheme walk would" { + const reference = struct { + fn fitEnd(text: []const u8, start: usize, width: usize) usize { + var end = start; + var used: usize = 0; + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; + } + return end; + } + }.fitEnd; + + const cases = [_][]const u8{ + "", + "hello world", + // the fast path must hand over at the first non-ASCII byte, mid-run + "abc\u{00e9}def", + // a wide glyph is two columns, so a width boundary can land inside it + "ab\u{4e16}\u{754c}cd", + // a cluster the fast path must not split + "a\u{0301}bc", + // tabs and controls are excluded from the fast path by the range test + "ab\tcd", + "ab\rcd", + // an ASCII byte followed by a continuation byte is NOT its own cluster + "e\u{0301}x", + "\u{1f1e6}\u{1f1e7}ok", + }; + for (cases) |text| { + var width: usize = 0; + while (width <= text.len + 3) : (width += 1) { + var start: usize = 0; + while (start <= text.len) : (start += 1) { + try std.testing.expectEqual( + reference(text, start, width), + fitEnd(text, start, width), + ); + } + } + } + } + + pub fn lineCount(content: []const u8) usize { + return std.mem.count(u8, content, "\n") + 1; + } + + // Content changes invalidate this lazily rebuilt byte-offset index. + pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { + if (f.line_starts.len > 0) return f.line_starts; + // Exact allocation: deinitPane frees `line_starts` itself, so the stored + // slice must span the complete allocation rather than spare capacity. + const starts = try gpa.alloc(usize, lineCount(f.content)); + starts[0] = 0; + var i: usize = 1; + var off: usize = 0; + while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| { + off = nl + 1; + starts[i] = off; + i += 1; + } + f.line_starts = starts; + return starts; + } + + /// line count, O(1) once the index is warm + pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { + const idx = lineIndex(gpa, f) catch return lineCount(f.content); + return idx.len; + } + + /// byte offset of line `row`, or content.len past the end — modal + /// .lineStartOffset's contract exactly, without its walk + pub fn lineStart(gpa: std.mem.Allocator, f: *State, row: usize) usize { + const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row); + return if (row >= idx.len) f.content.len else idx[row]; + } + + pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const []const u8 { + const index = try lineIndex(pane.gpa, f); + const lines = try arena.alloc([]const u8, index.len); + for (index, 0..) |start, i| { + const end = if (i + 1 < index.len) index[i + 1] - 1 else f.content.len; + lines[i] = f.content[start..end]; + } + return lines; + } + + /// Use the file's line index only when `text` is its complete live content. + /// Edit-buffer fragments and other temporary text retain modal's scan path. + fn contentIndex(pane: *Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (text.ptr != f.content.ptr or text.len != f.content.len) return null; + return lineIndex(pane.gpa, f) catch null; + } + + pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize { + const index = contentIndex(pane, text) orelse return modal.offsetAt(text, cursor); + const row = @min(cursor.row, index.len - 1); + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start)); + } + + pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize { + const index = contentIndex(pane, text) orelse return modal.lineStartOffset(text, row); + return if (row >= index.len) text.len else index[row]; + } + + pub fn textLine(pane: *Pane, text: []const u8, row: usize) []const u8 { + const index = contentIndex(pane, text) orelse return modal.lineSlice(text, row); + if (row >= index.len) return ""; + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return text[start..end]; + } + + test "indexed text rows preserve fragment and allocation failure semantics" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("first\n\tλ界\n\nlast\n"); + const text = pane.file.?.content; + allocator.fail_index = allocator.alloc_index; + for (0..lineCount(text) + 2) |row| try std.testing.expectEqualStrings(modal.lineSlice(text, row), textLine(pane, text, row)); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.line_starts.len); + allocator.fail_index = std.math.maxInt(usize); + _ = try lineIndex(p.gpa, &pane.file.?); + allocator.fail_index = allocator.alloc_index; + const allocations = allocator.allocations; + for ([_][]const u8{ text, text[2..], text[0..8], "other\nrows\n\n", "" }) |fragment| { + for (0..lineCount(fragment) + 2) |row| try std.testing.expectEqualStrings( + modal.lineSlice(fragment, row), + textLine(pane, fragment, row), + ); + } + try std.testing.expectEqual(allocations, allocator.allocations); + } + + pub fn textLineCount(pane: *Pane, text: []const u8) usize { + const index = contentIndex(pane, text) orelse return modal.cursorLineCount(text); + return index.len; + } + + pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor { + const index = contentIndex(pane, text) orelse return modal.positionAt(text, offset); + const bounded = @min(offset, text.len); + const row = std.sort.upperBound(usize, index, bounded, struct { + fn cmp(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.cmp) - 1; + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) }; + } + + 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); + errdefer p.gpa.destroy(pane); + const history = try History.create(p.gpa); + errdefer p.gpa.destroy(history); + p.reserved_slots[id] = true; + defer p.reserved_slots[id] = false; + const content = try filesystem.read(p, path); + errdefer p.gpa.free(content); + const total = lineCount(content); + const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; + pane.file = .{ .path = path_copy, .content = content, .scroll = scroll, .history = history }; + pane.cur_pinned = true; + pane.cur_row = @intCast(scroll); + p.installPane(id, pane); + if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return pane; + } + + pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane { + const saved = src.file.?; + if (saved.mini_source.len > 0) { + const encoded_limit = std.base64.standard.Encoder.calcSize(Mini.max_output_bytes); + if (saved.mini_source.len >= 4096 or saved.content.len > Mini.max_output_bytes or + saved.content_b64.len > encoded_limit or saved.mini_colors_b64.len > encoded_limit) + return error.InvalidMini; + } + const content: []u8 = if (saved.content_b64.len > 0) + try dump.decodeBytes(p.gpa, saved.content_b64) + else + try p.gpa.dupe(u8, saved.content); + errdefer p.gpa.free(content); + const path = try p.gpa.dupe(u8, saved.path); + errdefer p.gpa.free(path); + + const output: ?Output.State = if (Output.fromWord(saved.origin)) |origin| blk: { + var value: Output.State = .{ .from = origin }; + try Output.setArg(&value, saved.origin_arg); + break :blk value; + } else null; + + var mini: ?Mini.State = null; + errdefer if (mini) |*state| state.deinit(p.gpa); + if (saved.mini_source.len > 0) { + if (output == null or !std.meta.eql(output.?.from, Output.Origin{ .cmd = .Mini })) return error.InvalidMini; + const source = try p.gpa.dupe(u8, saved.mini_source); + errdefer p.gpa.free(source); + const colors = try dump.decodeBytes(p.gpa, saved.mini_colors_b64); + errdefer p.gpa.free(colors); + if (colors.len != content.len or colors.len > Mini.max_output_bytes) return error.InvalidMini; + for (colors) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMini; + mini = .{ .source = source, .colors = colors }; + } else if (saved.mini_colors_b64.len > 0) return error.InvalidMini; + + const history = try History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(id); + pane.file = .{ + .path = path, + .content = content, + .output = output, + .mini = mini, + .scroll = src.scroll, + .revision = @intFromBool(saved.dirty), + .history = history, + }; + pane.cur_pinned = true; + pane.cur_row = @intCast(src.scroll); + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + if (output == null and filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true, .mode = .baseline_disk } }); + return pane; + } + + pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { + if (file.output == null) for (p.panes, 0..) |slot, id| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + }; + p.gpa.free(file.path); + p.gpa.free(file.content); + if (file.mini) |*mini| mini.deinit(p.gpa); + if (file.line_starts.len > 0) p.gpa.free(file.line_starts); + if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); + for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content); + for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content); + p.gpa.destroy(file.history); + } + + fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { + if (p.fs.listeners == 0) return; + const id = for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + if (pane.file) |*state| if (state == f) break i; + } else return; + pardes.filesystem.noteReplace(p, id, false, f.content, new); + } + + pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + reportEdit(p, f, new); + if (f.mini) |*mini| mini.deinit(p.gpa); + f.mini = null; + p.gpa.free(f.content); + f.content = new; + f.revision +%= 1; + if (f.line_starts.len > 0) p.gpa.free(f.line_starts); + f.line_starts = &.{}; + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = true; + } + + pub fn restoreSnap(pane: *Pane, f: *State, snap: Snapshot) void { + const n = nlines(pane.gpa, f); + const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1); + const llen = modal.lineSlice(f.content, row).len; + pane.cur_row = @intCast(row); + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); + pane.vsel = snap.vsel; + pane.msel.active = false; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + + fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, snap: Snapshot) void { + if (len.* == slots.len) { + gpa.free(slots[0].content); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = snap; + len.* += 1; + } + + pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.undo_len > 0 and std.mem.eql(u8, history.undo[history.undo_len - 1].content, f.content)) return; + const snap: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.undo, &history.undo_len, snap); + for (history.redo[0..history.redo_len]) |item| p.gpa.free(item.content); + history.redo_len = 0; + } + + pub fn undo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.undo_len == 0) return; + const current: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.redo, &history.redo_len, current); + history.undo_len -= 1; + const previous = history.undo[history.undo_len]; + setContent(p, f, previous.content); + restoreSnap(pane, f, previous); + } + + pub fn redo(p: *Pardes, pane: *Pane) void { + const f = if (pane.file) |*file| file else return; + const history = f.history; + if (history.redo_len == 0) return; + const current: Snapshot = .{ + .content = p.gpa.dupe(u8, f.content) catch return, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }; + pushHistory(p.gpa, &history.undo, &history.undo_len, current); + history.redo_len -= 1; + const next = history.redo[history.redo_len]; + setContent(p, f, next.content); + restoreSnap(pane, f, next); + } + + /// Commit an externally rewritten file onto the same undo history as typed + /// edits. Unsaved work remains one `u` away; there is no third merge state. + pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void { + const pane = p.panes[id] orelse return; + const f = if (pane.file) |*file| file else return; + if (std.mem.eql(u8, f.content, bytes)) return; + const new = p.gpa.dupe(u8, bytes) catch return; + pushUndo(p, pane); + setContent(p, f, new); + f.saved_revision = f.revision; + // restoreSnap only consumes cursor/selection from this synthetic snapshot. + restoreSnap(pane, f, .{ + .content = undefined, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }); + } + + /// re-highlight the visible window of any file whose syntax went stale + /// (edit, scroll, load) — visible-range-first so big files stay snappy + pub fn refreshHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshHighlights"); + defer tz.end(); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file == null) continue; + const f = &pane.file.?; + if (f.mini != null) { + f.syntax_dirty = false; + continue; + } + if (!f.syntax_dirty) continue; + if (!p.settings.colors) { + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = false; + continue; + } + const need_start = lineStart(p.gpa, f, f.scroll); + const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS)); + if (f.highlights.len > 0 and need_start >= f.highlight_start and + need_end <= f.highlight_start + f.highlights.len) + { + f.syntax_dirty = false; + continue; + } + const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; + const whole = pane.colorAlgo() == .locations; + const start = if (whole) 0 else lineStart(p.gpa, f, f.scroll -| slack); + const end = if (whole) + f.content.len + else + @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); + const new_highlights = (switch (pane.colorAlgo()) { + .diff => syntax.highlightDiff(p.tree_sitter_gpa, f.content, start, end), + .locations => syntax.highlightLocations(p.tree_sitter_gpa, f.content, start, end), + else => syntax.highlightFileRange(p.tree_sitter_gpa, f.path, f.content, start, end), + }) catch { + f.syntax_dirty = false; + continue; + }; + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = new_highlights; + f.highlight_start = if (f.highlights.len > 0) start else 0; + f.syntax_dirty = false; + } + } + + pub fn wrapWidth(pane: *const Pane, wrap: bool) usize { + if (!wrap or pane.rows > pane.wrap_line.len) return 0; + return @max(1, @as(usize, pane.cols -| gutterWidth(pane)) -| 1); + } + + pub const VisualRow = struct { start: usize, end: usize }; + + pub fn visualRow(line: []const u8, col: usize, width: usize) VisualRow { + if (width == 0) return .{ .start = 0, .end = line.len }; + var start: usize = 0; + while (true) { + const end = fitEnd(line, start, width); + if (col < end or end >= line.len) return .{ .start = start, .end = end }; + start = end; + } + } + + pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *State, wrap: bool) ![]const u8 { + const width = wrapWidth(pane, wrap); + pane.wrap_n = 0; + + const len = fillBody(null, pane, f, width, false); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, f, width, true); + std.debug.assert(filled == out.len); + return out; + } + + /// Run the file-body row walk. With no destination it is the exact sizing + /// pass; with one it fills that allocation and records the wrapping map. + fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) usize { + if (record_wrap) pane.wrap_n = 0; + const prefix_width = gutterWidth(pane); + // start ON the first visible line instead of walking the file to it: this + // walk was O(f.scroll) and recolorSyntax below ran the identical one again + var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); + if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); + // the line the NEXT row comes from and the byte column of it that row + // starts at — the two the map records, walked forward by the loop + var abs: i32 = @intCast(f.scroll); + var at: usize = 0; + var cur = flines.next(); + var written: usize = 0; + for (0..pane.rows) |i| { + if (i > 0) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + if (width > 0 and record_wrap) { + pane.wrap_line[i] = abs; + pane.wrap_col[i] = @intCast(at); + pane.wrap_n = @intCast(i + 1); + } + if (cur) |text| { + var lbuf: [@max(config.PREFIX_W, 32)]u8 = undefined; + const prefix = lbuf[0..prefix_width]; + @memset(prefix, ' '); + if (at == 0) { + var lineno: usize = @intCast(abs + 1); + var digit: usize = prefix.len - 1; + while (true) { + digit -= 1; + prefix[digit] = '0' + @as(u8, @intCast(lineno % 10)); + lineno /= 10; + if (lineno == 0) break; + } + } + if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix); + written += prefix.len; + + const end = if (width == 0) text.len else fitEnd(text, at, width); + const take = end - at; + const cut = if (pane.hscroll > 0 and width == 0) + modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take)) + else + 0; + const shown = text[at + cut .. end]; + if (dst) |out| @memcpy(out[written..][0..shown.len], shown); + written += shown.len; + if (width > 0 and end < text.len) { + at = end; + } else { + abs += 1; + at = 0; + cur = flines.next(); + } + } else abs += 1; + } + return written; + } + + pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { + const s = &p.surface; + const prefix_width = gutterWidth(pane); + const ch = p.chromeTheme(); + const goff = pane.scroll(); + const gcur = Terminal.gridCursor(pane); + const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; + const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); + // the body's first row, the way renderPane derives it (Tagbottom) + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + const row_line: i32 = if (pane.wrap_n == 0) + goff + @as(i32, vr) + else if (vr < pane.wrap_n) pane.wrap_line[vr] else std.math.maxInt(i32); + const on_cursor = row_line == cur_line; + var c: u16 = 0; + while (c < prefix_width and c < tw) : (c += 1) { + const cell = s.at(tx + c, body_y + vr); + cell.default = false; // paints blank gutter rows too + if (on_cursor) { + cell.style.fg = .{ .rgb = ch.tag_fg }; + cell.style.bg = .{ .rgb = ch.tag_bg }; + } else cell.style.fg = .{ .rgb = ch.lineno }; + } + } + } + + const SynStyle = struct { fg: [3]u8, bold: bool }; + + fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { + return switch (sy) { + .none => null, + .keyword => .{ .fg = p.theme().kw, .bold = true }, + .string => .{ .fg = p.theme().str, .bold = false }, + .number => .{ .fg = p.theme().num, .bold = false }, + .comment => .{ .fg = p.theme().comment, .bold = true }, + }; + } + + /// syntax colors: recolor each content cell from its tree-sitter style byte; + /// content starts after the lineno gutter + pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { + const highlights = if (f.mini) |mini| mini.colors else f.highlights; + const highlight_start = if (f.mini != null) 0 else f.highlight_start; + if (highlights.len == 0) return; + const s = &p.surface; + const prefix_width = gutterWidth(pane); + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); + // indexed start, same as bodyText — an empty tail simply paints nothing + var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); + const total = nlines(p.gpa, f); + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + var base: usize = undefined; + var line: []const u8 = undefined; + var hs: usize = @intCast(@max(0, pane.hscroll)); + var limit: usize = undefined; + if (pane.wrap_n == 0) { + line = flines.next() orelse break; + base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); + limit = line.len; + } else { + if (vr >= pane.wrap_n) break; + const lrow: usize = @intCast(@max(0, pane.wrap_line[vr])); + if (lrow >= total) break; + base = lineStart(p.gpa, f, lrow); + const lend = if (lrow + 1 < total) lineStart(p.gpa, f, lrow + 1) -| 1 else f.content.len; + line = f.content[base..lend]; + hs = @intCast(pane.wrap_col[vr]); + limit = if (vr + 1 < pane.wrap_n and pane.wrap_line[vr + 1] == pane.wrap_line[vr]) + @min(line.len, @as(usize, @intCast(pane.wrap_col[vr + 1]))) + else + line.len; + } + hs = modal.graphemeStart(line, @min(hs, line.len)); + var c: usize = 0; + var screen_c: usize = 0; + while (hs + c < limit and prefix_width + screen_c < tw) { + const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c)); + const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]); + const idx = base + hs + c; + if (idx >= highlight_start) { + const hidx = idx - highlight_start; + if (hidx < highlights.len) { + if (synStyle(p, @enumFromInt(highlights[hidx]))) |ss| { + var fill: usize = 0; + while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } + } + } + screen_c += cells; + c = grapheme_end - hs; + } + } + } + + pub fn drawWrapMarkers( + p: *Pardes, + pane: *const Pane, + r: pardes.Rect, + tx: u16, + tw: u16, + body_h: u16, + pane_bg: pardes.Color, + ) void { + if (tw <= gutterWidth(pane) + 1) return; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const marker_fg = p.chromeTheme().lineno; + var row: u16 = 0; + while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { + if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue; + p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ + .fg = .{ .rgb = marker_fg }, + .bg = pane_bg, + }); + } + } + + pub fn paintWordSelection( + p: *Pardes, + pane: *Pane, + r: pardes.Rect, + row: i32, + word_lo: i32, + word_hi: i32, + bg: [3]u8, + ) void { + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + const prefix_width = gutterWidth(pane); + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var vr: i32 = 0; + while (vr + @as(i32, pardes.BOX_H) < @as(i32, r.h)) : (vr += 1) { + const here = pane.wrapAt(vr); + if (here.line != row) continue; + var hi = word_hi; + const next = pane.wrapAt(vr + 1); + if (next.line == row) hi = @min(hi, next.at); + const lo = @max(word_lo, here.at); + if (hi <= lo) continue; + const c0 = @as(i32, prefix_width) + displayOffset(pane, row, here.at, lo); + const c1 = @as(i32, prefix_width) + displayEndOffset(pane, row, here.at, hi - 1); + var col = @max(@as(i32, prefix_width), c0); + while (col <= c1 and col < @as(i32, tw)) : (col += 1) { + const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + } + } + } +}; + +pub const Output = struct { + const Builtin = builtins.registry.Builtin(); + const gui_shader_source_mode = effect_sources.guiShaderSourceMode(); + + const fonts = if (builtins.capabilities.font_picker) @import("fonts.zig") else struct {}; + + pub const Origin = union(enum) { + cmd: Builtin, + query: lsp.Kind, + search, + errors, + }; + + pub const max_arg = dump.max_origin_arg; + + pub const State = struct { + from: Origin, + arg_buf: [max_arg]u8 = undefined, + arg_len: u16 = 0, + + pub fn arg(o: *const State) []const u8 { + return o.arg_buf[0..o.arg_len]; + } + }; + + pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void { + if (text.len > max_arg) return error.ArgumentTooLong; + o.arg_len = @intCast(text.len); + @memcpy(o.arg_buf[0..o.arg_len], text); + } + + pub const Traits = builtins.OutputTraits; + + const file_row: Traits = .{ .name = "", .doc = true, .saves = true }; + + pub fn traits(o: Origin) Traits { + return switch (o) { + // rows are `location text`, so n/N walk them + .search => .{ .name = config.search_buffer, .steps = true }, + .errors => .{ .name = config.errors_buffer, .doc = true }, + .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, + .query => |k| switch (k) { + .hover => .{ .name = config.hover_buffer }, + // prose: an action list, a diff, a report about the backend + .code_action, .format, .status, .explain => .{ .name = config.lsp_buffer }, + .rename => .{ .name = config.search_buffer, .steps = true }, + .definition, .declaration, .type_definition, .implementation, .references => .{ + .name = config.search_buffer, + .steps = true, + .jumps = true, + }, + // The hierarchy kinds behave like references: a list of places, + // and a lone answer (one caller, one subtype) is a jump. + .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{ + .name = config.search_buffer, + .steps = true, + .jumps = true, + }, + .document_symbols, .workspace_symbols, .diagnostics, .workspace_diagnostics, .select_refs, .completion => .{ + .name = config.search_buffer, + .steps = true, + }, + }, + }; + } + + pub fn fileTraits(out: ?State) Traits { + return traits((out orelse return file_row).from); + } + + pub const Grain = enum { + word, + line, + whole, + }; + + pub fn grain(out: ?State) Grain { + const tr = fileTraits(out); + if (tr.commands) return .whole; + return if (tr.steps) .line else .word; + } + + pub fn word(o: Origin) []const u8 { + return switch (o) { + .cmd => |b| @tagName(b), + .query => |k| @tagName(k), + .search => "/", + .errors => config.errors_buffer, + }; + } + + pub fn fromWord(w: []const u8) ?Origin { + if (w.len == 0) return null; + if (std.mem.eql(u8, w, "/")) return .search; + if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; + if (std.meta.stringToEnum(Builtin, w)) |b| + if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; + if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; + return null; + } + + pub fn resultsFrom(p: *Pardes, pane: *Pane, from: Origin) bool { + const rp = p.panes[pane.search_pane orelse return false] orelse return false; + const f = rp.file orelse return false; + const o = f.output orelse return false; + return std.meta.eql(o.from, from); + } + + pub const Location = struct { + path: []const u8, + at: look.Spot, + end: usize, + + fn order(a: Location, b: Location) std.math.Order { + const path = std.mem.order(u8, a.path, b.path); + if (path != .eq) return path; + if (a.at.line != b.at.line) return std.math.order(a.at.line, b.at.line); + return std.math.order(a.at.col, b.at.col); + } + }; + + pub fn location(line: []const u8) Location { + const text = std.mem.trimEnd(u8, line, " \t\r\n"); + var at: usize = 1; + while (at < text.len) { + if (text[at] != ':' or at + 1 == text.len or !std.ascii.isDigit(text[at + 1])) { + at += 1; + continue; + } + const start = at; + at += 1; + while (at < text.len and (std.ascii.isDigit(text[at]) or text[at] == ':' or text[at] == '-')) at += 1; + if (at < text.len and text[at] != ' ' and text[at] != '\t') continue; + const parsed = look.parsePathLine(text[start..at]); + if (parsed.at.line == 0 or parsed.end != at - start) continue; + return .{ .path = text[0..start], .at = parsed.at, .end = at }; + } + return .{ .path = text, .at = .{}, .end = text.len }; + } + + fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?usize) !?usize { + switch (from) { + .search => {}, + .cmd => |cmd| switch (cmd) { + .Find, .Grep => {}, + else => return anchor, + }, + .query => |kind| switch (kind) { + .definition, + .declaration, + .type_definition, + .implementation, + .references, + .incoming_calls, + .outgoing_calls, + .supertypes, + .subtypes, + .document_symbols, + .workspace_symbols, + .diagnostics, + .workspace_diagnostics, + .select_refs, + .rename, + => {}, + else => return anchor, + }, + .errors => return anchor, + } + if (content.len == 0) return anchor; + const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); + const trailing_newline = content[content.len - 1] == '\n'; + const body = content[0 .. content.len - @intFromBool(trailing_newline)]; + var lines = std.mem.splitScalar(u8, body, '\n'); + var previous: ?Location = null; + var sorted = true; + var count: usize = 0; + while (lines.next()) |line| { + const current: Location = if (path_only) .{ .path = line, .at = .{}, .end = line.len } else location(line); + if (!path_only and current.at.line == 0) return anchor; + if (previous) |last| if (last.order(current) == .gt) { + sorted = false; + }; + previous = current; + count += 1; + } + if (sorted) return anchor; + + const Row = struct { + text: []const u8, + target: Location, + original: usize, + + fn lessThan(_: void, a: @This(), b: @This()) bool { + return switch (a.target.order(b.target)) { + .lt => true, + .eq => a.original < b.original, + .gt => false, + }; + } + }; + const rows = try arena.alloc(Row, count); + const copy = try arena.dupe(u8, body); + lines = std.mem.splitScalar(u8, copy, '\n'); + for (rows, 0..) |*row, original| { + const text = lines.next().?; + const target: Location = if (path_only) .{ .path = text, .at = .{}, .end = text.len } else location(text); + row.* = .{ + .text = text, + .target = target, + .original = original, + }; + } + std.mem.sort(Row, rows, {}, Row.lessThan); + var mapped = anchor; + var offset: usize = 0; + for (rows, 0..) |row, i| { + if (anchor == row.original) mapped = i; + @memcpy(content[offset..][0..row.text.len], row.text); + offset += row.text.len; + if (i + 1 < rows.len or trailing_newline) { + content[offset] = '\n'; + offset += 1; + } + } + std.debug.assert(offset == content.len); + return mapped; + } + + pub fn nextResult(content: []const u8, path: []const u8, at: look.Spot) usize { + const current: Location = .{ .path = path, .at = at, .end = 0 }; + var lines = std.mem.splitScalar(u8, content, '\n'); + var first: ?usize = null; + var row: usize = 0; + while (lines.next()) |line| : (row += 1) { + const target = location(line); + if (target.at.line == 0) continue; + if (first == null) first = row; + if (target.order(current) == .gt) return row; + } + return first orelse 0; + } + + pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ + std.mem.trimEnd(u8, dir, "/"), traits(from).name, + }); + errdefer p.gpa.free(path); + var out: State = .{ .from = from }; + try setArg(&out, arg); + const history = try File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(id); + pane.file = .{ .path = path, .content = content, .output = out, .history = history }; + pane.cur_pinned = true; + return pane; + } + + pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, initial_anchor: ?usize) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + const anchor = try sortResults(p.scratch.allocator(), from, content, initial_anchor); + const by_arg = std.meta.activeTag(from) != .query; + for (p.panes, 0..) |slot, i| { + if (i == id) continue; + const rp = slot orelse continue; + const rf = if (rp.file) |*f| f else continue; + const o = if (rf.output) |*x| x else continue; + if (!std.meta.eql(o.from, from)) continue; + if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue; + if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; + try setArg(o, arg); + if (std.mem.eql(u8, rf.content, content)) { + p.gpa.free(content); + } else { + File.setContent(p, rf, content); + resetBody(p, rp); + } + p.active = id; + if (traits(from).steps) { + pane.search_pane = i; + pane.search_row = anchor; + p.armLookWalk(i); + } + return; + } + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, arg, content); + p.placeDoc(id, free, np); + p.active = id; + if (traits(from).steps) { + pane.search_pane = free; + pane.search_row = anchor; + p.armLookWalk(free); + } + } + + pub fn openJumps(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (p.jumps[0..p.njumps]) |j| { + const jp = p.panes[j.pane] orelse continue; + var idbuf: [16]u8 = undefined; + const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; + const has_path = if (jp.file) |f| f.output == null else pdf_path != null; + const loc: []const u8 = if (has_path) + (if (jp.file) |f| f.path else pdf_path.?) + else + std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable; + const what: []const u8 = if (jp.file) |f| + std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") + else if (jp.image) |iv| + iv.path + else if (pdf_path) |path| + path + else + jp.cwdSlice(); + var cut = @min(what.len, 120); + while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; + if (j.line == 0) + try out.writer.print("{s} {s}\n", .{ loc, what[0..cut] }) + else + try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }); + } + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .Jumplist }, content); + } + + pub fn openThemes(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (pardes.themes) |t| + try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name}); + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .ThemeSel }, content); + } + + pub fn openFonts(p: *Pardes, id: usize) !void { + if (builtins.capabilities.font_picker) { + const arena = p.scratch.allocator(); + const font_list = fonts.list(arena, null); + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + for (font_list) |f| + try out.writer.print(comptime config.Runtime.findAction(.font).?.word ++ " {s}\n", .{f.name}); + const content = try out.toOwnedSlice(); + try openStepped(p, id, .{ .cmd = .FontSel }, content); + } + } + + fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, "", content); + p.placeDoc(id, free, np); + p.active = id; + pane.search_pane = free; + pane.search_row = null; + p.armLookWalk(free); + } + + fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 { + const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n"; + const group_header = "pardes builtins under SPC"; + const language_footer = + "\nlanguage keys (motions, not words):\n" ++ + "gd gD gy gi gr goto: definition,\n" ++ + " declaration, type-def,\n" ++ + " implementation, refs\n" ++ + "]d [d ]D [D diagnostics: next,\n" ++ + " prev, last, first\n" ++ + "= format (applies, one\n" ++ + " undo step)\n" ++ + "Tab after a . completion, in insert\n" ++ + "C-left-click definition, by mouse\n" ++ + "SPC l ... hover, rename, symbols,\n" ++ + " calls, types: above\n"; + var len: usize = if (prefix.len == 0) + full_header.len + language_footer.len + else + group_header.len + prefix.len * 2 + 2; + for (pardes.builtin_rows) |row| { + // a path-less builtin filters as the empty path: in the full listing + // (which starts with nothing) and out of every group + if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; + len += row.line.len + 1; + } + const content = try gpa.alloc(u8, len); + var at: usize = 0; + if (prefix.len == 0) { + @memcpy(content[0..full_header.len], full_header); + at = full_header.len; + } else { + @memcpy(content[0..group_header.len], group_header); + at = group_header.len; + for (prefix) |c| { + content[at] = ' '; + content[at + 1] = c; + at += 2; + } + content[at] = '\n'; + content[at + 1] = '\n'; + at += 2; + } + for (pardes.builtin_rows) |row| { + if (!std.mem.startsWith(u8, row.path orelse "", prefix)) continue; + @memcpy(content[at..][0..row.line.len], row.line); + at += row.line.len; + content[at] = '\n'; + at += 1; + } + if (prefix.len == 0) { + @memcpy(content[at..][0..language_footer.len], language_footer); + at += language_footer.len; + } + std.debug.assert(at == content.len); + return content; + } + + pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void { + const content = try helpContent(p.gpa, prefix); + // content is handed off unfreed on purpose: openRead adopts it or frees + // it, and nothing between the alloc above and this line can fail. + return openRead(p, id, .{ .cmd = .Help }, prefix, content); + } + + test "full Help renders every builtin row, then the language keys" { + const content = try helpContent(std.testing.allocator, ""); + defer std.testing.allocator.free(content); + + // FIRST blank line: the end of the header (the footer opens with one too) + const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse + return error.MissingHelpHeader) + 2 ..]; + var lines = std.mem.splitScalar(u8, body, '\n'); + for (pardes.builtin_rows) |row| + try std.testing.expectEqualStrings(row.line, lines.next() orelse + return error.MissingBuiltinHelpRow); + // ...and after the last row, the language-keys section: the one part of + // the keymap no builtin row can carry, closing the page. + try std.testing.expectEqualStrings("", lines.next() orelse + return error.MissingLanguageKeys); + try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse + return error.MissingLanguageKeys); + try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null); + // the group view stays a pure filter: no footer under a prefix + const group = try helpContent(std.testing.allocator, "l"); + defer std.testing.allocator.free(group); + try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null); + } + + pub fn openConfig(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try config.Runtime.writeReport(&out.writer, .{ + .startup_config_path = p.opts.startup_config_path, + .platform = @tagName(pardes.platform), + .theme_name = p.theme().name, + .compiled_default_shell = config.default_shell, + .gui_shader_source_mode = if (gui_shader_source_mode) |mode| + mode.label() + else + null, + .hover_delay_frames = config.look_preview_delay_frames, + .native_images = p.native_images, + .capabilities = builtins.capabilities, + .state = &p.settings, + }); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Config }, "", content); + } + + /// The message-row log, oldest first — the lines that were said in passing and + /// then cleared by the next keystroke. + 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.messageLog(i)) |m| : (i += 1) { + if (m.pane != 0xff) try out.writer.print("{d}: ", .{m.pane}); + try out.writer.writeAll(m.slice()); + if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); + try out.writer.writeByte('\n'); + } + if (i == 0) try out.writer.writeAll("nothing has been said yet\n"); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Messages }, "", content); + } + + /// The version banner plus the embedded CHANGELOG, so an installed binary can + /// say what it is and what changed without a repository beside it. + pub fn openChangelog(p: *Pardes, id: usize) !void { + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try out.writer.print("pardes {s}\n\n", .{build_options.version}); + try out.writer.writeAll(@embedFile("CHANGELOG.md")); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .Changelog }, "", content); + } + + pub fn openEffectCode(p: *Pardes, id: usize, argument: []const u8) !void { + const name = std.mem.trim(u8, argument, " \t\r\n"); + const setting = config.Runtime.find(name) orelse return error.UnknownEffect; + switch (setting.action) { + .transition, .scene => {}, + else => return error.NotAnEffect, + } + if (!setting.enabled(builtins.capabilities)) return error.EffectUnavailable; + const paths = effect_sources.forSetting(setting) orelse + return error.EffectUnavailable; + + var out: std.Io.Writer.Allocating = .init(p.gpa); + errdefer out.deinit(); + try out.writer.print("EffectCode {s} ({s})\n", .{ setting.word, @tagName(effect_sources.backend) }); + if (gui_shader_source_mode) |mode| + try out.writer.print("GUI shader source: {s}\n", .{mode.label()}); + try out.writer.writeByte('\n'); + for (paths) |path| try out.writer.print("/virtual/{s}\n", .{path}); + const content = try out.toOwnedSlice(); + return openRead(p, id, .{ .cmd = .EffectCode }, setting.word, content); + } + + pub fn openErrors(p: *Pardes, id: usize, content: []u8) !void { + return openRead(p, id, .errors, "", content); + } + + fn resetBody(p: *Pardes, pane: *Pane) void { + pane.file.?.scroll = 0; + pane.cur_row = 0; + pane.cur_col = 0; + pane.cur_pinned = true; + pane.hscroll = 0; + pane.wrap_n = 0; + pane.msel = .{}; + pane.vsel = .{}; + pane.nsel = 0; + pane.nsel_snap = 0; + pane.select = false; + pane.sel = @splat(.{}); + pane.sticky_col = -1; + pane.append_at = null; + pane.normal.clear(); + pane.look_at = null; + if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { + p.look_hover_wait = null; + }; + if (p.look_hover_preview) |preview| if (preview.serial == pane.serial) { + p.look_hover_preview = null; + }; + if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none; + } + + fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { + errdefer p.gpa.free(content); + const pane = p.panes[id] orelse return error.MissingPane; + for (p.panes, 0..) |slot, i| { + const hp = slot orelse continue; + const hf = if (hp.file) |*f| f else continue; + const ho = if (hf.output) |*o| o else continue; + if (!std.meta.eql(ho.from, from)) continue; + try setArg(ho, arg); + File.setContent(p, hf, content); + resetBody(p, hp); + p.active = i; + return; + } + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse return error.NoPaneSlots; + const np = try open(p, free, dir, from, arg, content); + p.placeDoc(id, free, np); + p.active = free; + } +}; + +pub const Mini = struct { + pub const max_input_bytes = 4 * 1024 * 1024; + pub const max_output_bytes = 4 * 1024 * 1024; + + pub const State = struct { + source: []u8, + colors: []u8, + + pub fn deinit(state: *State, gpa: std.mem.Allocator) void { + gpa.free(state.source); + gpa.free(state.colors); + state.* = undefined; + } + }; + + pub const Result = struct { + content: []u8, + colors: []u8, + + pub fn deinit(result: Result, gpa: std.mem.Allocator) void { + gpa.free(result.content); + gpa.free(result.colors); + } + }; + + const Row = struct { + text: []const u8 = "", + base: usize = 0, + at: usize = 0, + left: usize = 0, + ink: bool = false, + color: u8 = 0, + + fn dot(row: *Row, styles: []const u8) ?u8 { + if (row.left == 0) { + if (row.at == row.text.len) return null; + const end = modal.nextGrapheme(row.text, row.at); + const grapheme = row.text[row.at..end]; + row.left = File.graphemeDisplayWidth(grapheme); + const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; + const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; + const blank = switch (cp) { + '\t'...'\r', ' ', 0x85, 0xa0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true, + else => false, + }; + row.ink = !blank or grapheme.len != n; + row.color = if (styles.len == 0) 0 else styles[row.base + row.at]; + row.at = end; + } + row.left -= 1; + return if (row.ink) row.color else null; + } + }; + + fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { + if (source.len == 0) return 0; + const end = source.len - @intFromBool(source[source.len - 1] == '\n'); + var lines = std.mem.splitScalar(u8, source[0..end], '\n'); + var offset: usize = 0; + while (lines.peek() != null) { + var rows: [4]Row = @splat(.{}); + for (&rows) |*row| { + const line = lines.next() orelse break; + row.text = std.mem.trimEnd(u8, line, "\r"); + row.base = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); + } + var spaces: usize = 0; + while (true) { + var more = false; + for (rows) |row| more = more or row.at < row.text.len or row.left > 0; + if (!more) break; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + var mask: u8 = 0; + var counts: [5]u8 = @splat(0); + for (&rows, 0..) |*row, y| { + for (0..2) |x| { + if (row.dot(styles)) |color| { + mask |= @as(u8, 1) << bits[y][x]; + counts[color] += 1; + } + } + } + if (mask == 0) { + spaces += 1; + continue; + } + if (spaces + 3 > max_output_bytes - offset) return error.MiniTooLarge; + var color: u8 = 0; + var most: u8 = 0; + for (counts[1..], 1..) |count, i| { + if (count > most) { + color = @intCast(i); + most = count; + } + } + if (output) |out| { + @memset(out.content[offset..][0..spaces], ' '); + @memset(out.colors[offset..][0..spaces], 0); + _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; + @memset(out.colors[offset + spaces ..][0..3], color); + } + offset += spaces + 3; + spaces = 0; + } + if (offset == max_output_bytes) return error.MiniTooLarge; + if (output) |out| { + out.content[offset] = '\n'; + out.colors[offset] = 0; + } + offset += 1; + } + return offset; + } + + pub fn generate(gpa: std.mem.Allocator, source: []const u8, styles: []const u8) !Result { + if (source.len > max_input_bytes) return error.MiniTooLarge; + if (!std.unicode.utf8ValidateSlice(source)) return error.InvalidUtf8; + if (styles.len != 0 and styles.len != source.len) return error.InvalidMiniColors; + for (styles) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMiniColors; + const len = try render(null, source, styles); + const content = try gpa.alloc(u8, len); + errdefer gpa.free(content); + const colors = try gpa.alloc(u8, len); + errdefer gpa.free(colors); + const result: Result = .{ .content = content, .colors = colors }; + const written = try render(result, source, styles); + std.debug.assert(written == len); + return result; + } + + pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { + const caller = p.panes[id] orelse return error.MissingPane; + const word = std.mem.trim(u8, argument, " \t\r\n"); + if (word.len == 0) return error.MissingPath; + var path_buf: [4096]u8 = undefined; + const target = filesystem.resolve(p, word, Pardes.paneDir(caller), &path_buf) orelse return error.FileNotFound; + if (target.dir) return error.NotAFile; + const source = try p.gpa.dupe(u8, target.path); + errdefer p.gpa.free(source); + const input = try filesystem.readLimit(p, source, max_input_bytes); + defer p.gpa.free(input); + const styles = try syntax.highlightFileRange(p.tree_sitter_gpa, source, input, 0, input.len); + defer p.tree_sitter_gpa.free(styles); + const result = try generate(p.gpa, input, styles); + errdefer result.deinit(p.gpa); + for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + const file = if (pane.file) |*f| f else continue; + const old = file.mini orelse continue; + if (!std.mem.eql(u8, old.source, source)) continue; + if (std.mem.eql(u8, file.content, result.content) and std.mem.eql(u8, old.colors, result.colors)) { + p.gpa.free(source); + result.deinit(p.gpa); + } else { + File.setContent(p, file, result.content); + file.mini = .{ .source = source, .colors = result.colors }; + file.syntax_dirty = false; + Output.resetBody(p, pane); + } + p.active = i; + return; + } + const free = p.freeSlot() orelse return error.NoPaneSlots; + const dir = std.fs.path.dirname(source) orelse "/"; + const path = try std.fmt.allocPrint(p.gpa, "{s}/Mini {s}", .{ std.mem.trimEnd(u8, dir, "/"), std.fs.path.basename(source) }); + errdefer p.gpa.free(path); + const history = try File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(free); + pane.file = .{ + .path = path, + .content = result.content, + .output = .{ .from = .{ .cmd = .Mini } }, + .mini = .{ .source = source, .colors = result.colors }, + .history = history, + .syntax_dirty = false, + }; + pane.cur_pinned = true; + p.placeDoc(id, free, pane); + p.active = free; + } + + test "Mini maps every braille dot and partial line group" { + const gpa = std.testing.allocator; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + for (0..256) |mask| { + var source: [12]u8 = undefined; + for (0..4) |row| { + for (0..2) |col| source[row * 3 + col] = if (mask & (@as(usize, 1) << bits[row][col]) != 0) 'x' else ' '; + source[row * 3 + 2] = '\n'; + } + const result = try generate(gpa, &source, ""); + defer result.deinit(gpa); + var expected: [4]u8 = undefined; + const len: usize = if (mask == 0) 0 else try std.unicode.utf8Encode(@as(u21, 0x2800) + @as(u21, @intCast(mask)), &expected); + expected[len] = '\n'; + try std.testing.expectEqualStrings(expected[0 .. len + 1], result.content); + try std.testing.expectEqual(result.content.len, result.colors.len); + for (result.colors) |color| try std.testing.expectEqual(@as(u8, 0), color); + } + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "", .expected = "" }, + .{ .source = "x", .expected = "⠁\n" }, + .{ .source = "xx\n", .expected = "⠉\n" }, + .{ .source = "x \n", .expected = "⠁\n" }, + .{ .source = "\n\n\n\nx", .expected = "\n⠁\n" }, + .{ .source = "x\r\nx\r\n", .expected = "⠃\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } + } + + test "Mini uses display cells for tabs combining text and wide characters" { + const gpa = std.testing.allocator; + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "e\u{301}界\n", .expected = "⠉⠁\n" }, + .{ .source = " x\n", .expected = " ⠁\n" }, + .{ .source = "\u{a0}x\n", .expected = "⠈\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } + const tabs = try generate(gpa, "\tx\n", ""); + defer tabs.deinit(gpa); + const spaces = config.tab_width / 2; + for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte); + try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]); + } + + test "Mini chooses highlighted dots over plain ink with stable color ties" { + const gpa = std.testing.allocator; + const source = "xx\nxx\nxx\nxx\n"; + var styles: [source.len]u8 = @splat(0); + styles[0] = @intFromEnum(syntax.Syn.keyword); + const rare = try generate(gpa, source, &styles); + defer rare.deinit(gpa); + try std.testing.expectEqualStrings("⣿\n", rare.content); + try std.testing.expectEqualSlices(u8, &.{ 1, 1, 1, 0 }, rare.colors); + styles[0] = @intFromEnum(syntax.Syn.string); + styles[1] = @intFromEnum(syntax.Syn.number); + const tied = try generate(gpa, source, &styles); + defer tied.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 2, 2, 2, 0 }, tied.colors); + styles[3] = @intFromEnum(syntax.Syn.number); + const majority = try generate(gpa, source, &styles); + defer majority.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 3, 3, 3, 0 }, majority.colors); + } + + test "Mini generation bounds and allocation failures leave no partial result" { + const Case = struct { + fn run(gpa: std.mem.Allocator) !void { + const result = try generate(gpa, "alpha\nbeta\ngamma\ndelta\nepsilon\n", ""); + defer result.deinit(gpa); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{}); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "x", &.{5})); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "xx", &.{0})); + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + const source = try std.testing.allocator.alloc(u8, max_input_bytes + 1); + defer std.testing.allocator.free(source); + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source, "")); + for (source[0..max_input_bytes], 0..) |*byte, i| byte.* = if (i % 2 == 0) 'x' else ' '; + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source[0..max_input_bytes], "")); + try std.testing.expect(!allocator.has_induced_failure); + } + + test "Mini publishes only complete snapshots and content replacement drops metadata" { + const Case = struct { + fn run(gpa: std.mem.Allocator, path: []const u8) !void { + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("untouched\n"); + const free = p.freeSlot(); + open(p, 0, path) catch |err| { + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(free, p.freeSlot()); + try std.testing.expectEqual(source, p.panes[0].?); + try std.testing.expectEqualStrings("untouched\n", source.file.?.content); + return err; + }; + const file = &p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings("⠉\n", file.content); + try std.testing.expectEqualStrings(path, file.mini.?.source); + const replacement = try gpa.dupe(u8, "plain\n"); + File.setContent(p, file, replacement); + try std.testing.expect(file.mini == null); + try std.testing.expectEqualStrings("plain\n", file.content); + } + }; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "mini.txt", .data = "xx\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); + } +}; + +pub const Image = struct { + const GridKey = struct { + cols: u16 = 0, + rows: u16 = 0, + palette: image.PaletteMode = .commodore, + ascii: bool = true, + }; + + pub const State = struct { + path: []u8, + glyph_art: bool = false, + pmode: image.PaletteMode = .commodore, + ascii: bool = true, + tried: bool = false, + rgba: []u8 = &.{}, + iw: usize = 0, + ih: usize = 0, + /// Dump-loaded bytes, decoded lazily by the same path as a disk image. + raw: []u8 = &.{}, + grid: image.GlyphArt.Grid = .{ .cells = &.{}, .cols = 0, .rows = 0 }, + grid_key: GridKey = .{}, + + pub fn deinit(state: *State, gpa: std.mem.Allocator) void { + gpa.free(state.path); + if (state.rgba.len > 0) gpa.free(state.rgba); + if (state.raw.len > 0) gpa.free(state.raw); + if (state.grid.cells.len > 0) gpa.free(state.grid.cells); + state.* = undefined; + } + }; + + /// Construct an image pane from a path and optionally transferred dump bytes. + /// `raw` must be image_gpa-owned and ownership transfers only on success. + pub fn create(p: *pardes.Pardes, id: usize, path: []const u8, raw: []u8) !*pardes.Pane { + const path_copy = try p.image_gpa.dupe(u8, path); + errdefer p.image_gpa.free(path_copy); + const pane = try p.newDocPane(id); + pane.image = .{ .path = path_copy, .raw = raw }; + return pane; + } + + /// Serialize the binary image record used by images and, for dump-schema + /// compatibility, PDFs. Common pane metadata is supplied by the core. + pub fn dumpPane( + p: *pardes.Pardes, + arena: std.mem.Allocator, + pane: *const pardes.Pane, + tag: []const u8, + body: []const u8, + scroll: usize, + path: []const u8, + raw: []const u8, + ) !dump.Pane { + const bytes = if (raw.len > 0) raw else filesystem.read(p, path) catch &.{}; + defer if (raw.len == 0) p.gpa.free(bytes); + return .{ + .kind = .image, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .image = .{ + .path = path, + .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", + .petscii = if (pane.image) |state| state.glyph_art else false, + .palette = if (pane.image) |state| switch (state.pmode) { + .commodore => .commodore, + .terminal => .terminal, + } else .commodore, + .ascii = if (pane.image) |state| state.ascii else true, + }, + }; + } + + pub fn restore(p: *pardes.Pardes, id: usize, src: dump.Pane) !*pardes.Pane { + const saved = src.image.?; + var raw: []u8 = if (saved.bytes_b64.len > 0) + try dump.decodeBytes(p.image_gpa, saved.bytes_b64) + else + &.{}; + errdefer if (raw.len > 0) p.image_gpa.free(raw); + const pane = try create(p, id, saved.path, raw); + raw = &.{}; + pane.image.?.glyph_art = saved.petscii; + pane.image.?.pmode = switch (saved.palette) { + .commodore => .commodore, + .terminal => .terminal, + }; + pane.image.?.ascii = saved.ascii; + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + return pane; + } + + pub fn toggleGlyphArt(state: *State) void { + state.glyph_art = !state.glyph_art; + } + + pub fn togglePalette(state: *State) void { + state.pmode = if (state.pmode == .commodore) .terminal else .commodore; + } + + pub fn toggleAscii(state: *State) void { + state.ascii = !state.ascii; + } + + /// Image renderer choices are pane-local, not global Config values. Keep them + /// queryable where they apply: the live tag beside the image's path. + pub fn tagPrefix(arena: std.mem.Allocator, state: *const State) ![]u8 { + return std.fmt.allocPrint( + arena, + "{s} petscii:{s} palette:{s} ascii:{s} {s}", + .{ + config.tag_image, + if (state.glyph_art) "on" else "off", + @tagName(state.pmode), + if (state.ascii) "on" else "off", + state.path, + }, + ); + } + + pub fn legacySavedPrefix(state: *const State, saved_tag: []const u8) ?[]const u8 { + const lead = config.tag_image ++ " "; + if (!std.mem.startsWith(u8, saved_tag, lead)) return null; + const path_at = lead.len; + if (!std.mem.startsWith(u8, saved_tag[path_at..], state.path)) return null; + return saved_tag[0 .. path_at + state.path.len]; + } + + fn ensureDecoded(p: *pardes.Pardes, state: *State) void { + if (state.tried) return; + state.tried = true; + const bytes: []const u8 = if (state.raw.len > 0) + state.raw + else + filesystem.read(p, state.path) catch &.{}; + defer if (state.raw.len == 0) p.gpa.free(bytes); + if (image.decode(p.image_gpa, bytes)) |decoded| { + state.rgba = decoded.rgba; + state.iw = decoded.w; + state.ih = decoded.h; + } + } + + fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { + const wanted = GridKey{ .cols = cols, .rows = rows, .palette = state.pmode, .ascii = state.ascii }; + if (state.grid.cells.len > 0 and std.meta.eql(state.grid_key, wanted)) return; + if (state.grid.cells.len > 0) p.image_gpa.free(state.grid.cells); + const palette = switch (state.pmode) { + .commodore => image.GlyphArt.commodore, + .terminal => image.terminal_palette, + }; + state.grid = image.GlyphArt.render( + p.image_gpa, + state.rgba, + state.iw, + state.ih, + cols, + rows, + palette, + state.ascii, + ) catch .{ .cells = &.{}, .cols = 0, .rows = 0 }; + state.grid_key = wanted; + } + + pub fn draw( + p: *pardes.Pardes, + state: *State, + pane_id: u8, + serial: u32, + x: u16, + y: u16, + cols: u16, + rows: u16, + ) void { + ensureDecoded(p, state); + if (state.rgba.len == 0 or cols == 0 or rows == 0) return; + if (!state.glyph_art and p.native_images) { + _ = p.appendImagePlace(.{ + .pane = pane_id, + .serial = serial, + .x = x, + .y = y, + .w = cols, + .h = rows, + .rgba = state.rgba, + .iw = state.iw, + .ih = state.ih, + }); + return; + } + + ensureGrid(p, state, cols, rows); + if (state.grid.cells.len == 0) return; + const offx = if (cols > state.grid.cols) (@as(usize, cols) - state.grid.cols) / 2 else 0; + const offy = if (rows > state.grid.rows) (@as(usize, rows) - state.grid.rows) / 2 else 0; + for (0..state.grid.rows) |cy| for (0..state.grid.cols) |cx| { + const cell = &state.grid.cells[cy * state.grid.cols + cx]; + const fg: pardes.Color = switch (state.pmode) { + .commodore => .{ .rgb = image.GlyphArt.commodore[cell.fg] }, + .terminal => .{ .index = cell.fg }, + }; + const bg: pardes.Color = switch (state.pmode) { + .commodore => .{ .rgb = image.GlyphArt.commodore[cell.bg] }, + .terminal => .{ .index = cell.bg }, + }; + const sx = x + @as(u16, @intCast(offx + cx)); + const sy = y + @as(u16, @intCast(offy + cy)); + if (sx < p.surface.cols and sy < p.surface.rows) + p.surface.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg }); + }; + } +}; + +pub const Pdf = struct { + pub const enabled = @import("pardes_config").mupdf; + pub const pdf = if (enabled) @import("mupdf") else struct { + pub const PageSize = struct { width: f32, height: f32 }; + pub const Raster = struct { + width: usize = 0, + height: usize = 0, + stride: usize = 0, + len: usize = 0, + pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 }; + pub fn wholePage(_: @This()) Band { + return .{}; + } + pub fn band(_: @This(), _: usize, _: usize) Band { + return .{}; + } + }; + }; + pub const Point = if (enabled) pdf.Point else void; + pub const Quad = if (enabled) pdf.Quad else void; + pub const Document = if (enabled) pdf.Document else opaque {}; + pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void; + const raster_max = 256; + const raster_spare = 4; + pub const page_gap_px: u32 = 8; + const band_grain: usize = 64; + + pub const FitMode = if (enabled) enum { width, height } else void; + pub const TintMode = if (enabled) pdf.TintMode else void; + pub const TintColors = if (enabled) pdf.TintColors else void; + pub const RenderRequest = if (enabled) pdf.RenderRequest else void; + + /// Dump records must remain recognizable as PDFs even in a build without + /// MuPDF, where Look deliberately treats them as ordinary files. + pub fn isPath(path: []const u8) bool { + return std.ascii.endsWithIgnoreCase(path, ".pdf"); + } + + pub const RasterPolicy = struct { + dpi: u16, + max_dimension: u16, + match_viewport: bool, + }; + + pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; + const marker = " PdfSections "; + const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; + const path_at = marker_at + marker.len; + if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null; + return saved_tag[0 .. path_at + path.len]; + } + + pub const TintKey = if (enabled) struct { + mode: TintMode, + colors: pdf.TintColors, + + pub fn eql(a: @This(), b: @This()) bool { + return a.mode == b.mode and + (a.mode == .disabled or std.meta.eql(a.colors, b.colors)); + } + } else void; + + pub const Highlight = if (enabled) pdf.Highlight else void; + pub const Highlights = if (enabled) struct { + items: []const Highlight, + /// Hover items occupy [0..active_start); search/selection follow them. + active_start: usize, + hover_page: ?usize, + + pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight { + const hover = highlights.items[0..highlights.active_start]; + if (page == active_page) + return if (highlights.hover_page == page) + highlights.items + else + highlights.items[highlights.active_start..]; + return if (highlights.hover_page == page) hover else &.{}; + } + } else void; + + pub const HighlightInput = if (enabled) struct { + hover_quads: []const Quad = &.{}, + hover_page: ?usize = null, + hover_color: [3]u8, + selection_color: [3]u8, + } else void; + + pub fn buildHighlights( + state: *const State, + arena: std.mem.Allocator, + input: HighlightInput, + ) !Highlights { + if (comptime !enabled) return; + const search_len = if (state.search_results) |results| results.quads.len else 0; + const selection_len = if (state.selection) |selection| selection.quads.len else 0; + const active_start = input.hover_quads.len; + const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len); + var n: usize = 0; + for (input.hover_quads) |quad| { + highlights[n] = pdf.Highlight.init( + quad, + .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c }, + .custom, + ); + n += 1; + } + if (state.search_results) |results| { + for (results.quads) |item| { + highlights[n] = pdf.Highlight.init( + item.quad, + .{ 0xff, 0xd5, 0x4f, 0x70 }, + .search, + ); + n += 1; + } + } + if (state.selection) |selection| { + for (selection.quads) |quad| { + highlights[n] = pdf.Highlight.init( + quad, + .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 }, + .selection, + ); + n += 1; + } + } + return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page }; + } + + pub const Raster = if (enabled) struct { + valid: bool = false, + page: usize = 0, + rgba: []u8 = &.{}, + /// Full page shape; rgba contains only the band below. + iw: usize = 0, + ih: usize = 0, + band_y: usize = 0, + band_h: usize = 0, + request: pdf.RenderRequest = .{}, + request_valid: bool = false, + tried: bool = false, + decorated: bool = false, + tint_key: ?TintKey = null, + revision: u32 = 0, + } else void; + + pub const SectionsOutput = if (enabled) struct { + pane: usize, + serial: u32, + revision: u32, + } else void; + + pub const SelectionUpdate = enum { failed, stationary, unchanged, changed }; + + pub const State = if (enabled) struct { + path: []u8, + document: Document, + page: usize = 0, + page_count: usize, + page_sizes: []pdf.PageSize, + page_starts: []u64, + page_heights: []u32, + document_height: u64 = 0, + layout_viewport_w: u32 = 0, + layout_viewport_h: u32 = 0, + layout_fit: FitMode = .width, + layout_valid: bool = false, + document_scroll_y: f64 = 0, + scroll_to_page_pending: bool = true, + rasters: [raster_max]Raster = undefined, + rasters_len: usize = 0, + spare: [raster_spare][]u8 = @splat(&.{}), + spare_len: usize = 0, + layout_anchor_pending: bool = false, + layout_anchor_page: usize = 0, + layout_anchor_fraction: f64 = 0, + next_raster_revision: u32 = 0, + scroll_travel: f64 = 0, + fit: FitMode = .width, + tint: TintMode = .filtered, + pan_x: u16 = 0, + pan_y: u16 = 0, + highlights_dirty: bool = false, + search_reveal_pending: bool = false, + search_results: ?pdf.SearchResults = null, + selection: ?pdf.Selection = null, + selection_text: []u8 = &.{}, + selection_anchor: ?Point = null, + selection_head: ?Point = null, + drag_anchor: ?Point = null, + drag_head: ?Point = null, + text: []u8 = &.{}, + text_tried: bool = false, + text_scroll: usize = 0, + text_scroll_clamp_pending: bool = false, + search_query: []u8 = &.{}, + search_hit: usize = 0, + reveal_viewport_w: u32 = 0, + reveal_viewport_h: u32 = 0, + reveal_fit: FitMode = .width, + reveal_viewport_valid: bool = false, + outline: ?pdf.Outline = null, + outline_tried: bool = false, + sections_output: ?SectionsOutput = null, + outline_reveal_pending: ?pdf.OutlineInternalDestination = null, + + pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() { + const local = filesystem.localPath(path) orelse return error.NonLocalPath; + return initDocument(gpa, path, try Document.open(local), page_one_based); + } + + pub fn openBytes(gpa: std.mem.Allocator, path: []const u8, bytes: []const u8, page_one_based: usize) !@This() { + return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based); + } + + fn initDocument(gpa: std.mem.Allocator, path: []const u8, opened: Document, page_one_based: usize) !@This() { + var document = opened; + errdefer document.deinit(); + const page_sizes = try gpa.alloc(pdf.PageSize, document.pages); + errdefer gpa.free(page_sizes); + for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page); + const page_starts = try gpa.alloc(u64, document.pages); + errdefer gpa.free(page_starts); + const page_heights = try gpa.alloc(u32, document.pages); + errdefer gpa.free(page_heights); + const owned_path = try gpa.dupe(u8, path); + errdefer gpa.free(owned_path); + return .{ + .path = owned_path, + .document = document, + .page = if (page_one_based > 0) + @min(page_one_based - 1, document.pages - 1) + else + 0, + .page_count = document.pages, + .page_sizes = page_sizes, + .page_starts = page_starts, + .page_heights = page_heights, + }; + } + + pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void { + const preserve_anchor = !state.scroll_to_page_pending and + (state.layout_anchor_pending or + (state.layout_valid and state.page_count > 0 and state.document_height > 0)); + var anchor_page: usize = state.layout_anchor_page; + var anchor_fraction: f64 = state.layout_anchor_fraction; + if (preserve_anchor and !state.layout_anchor_pending) { + anchor_page = pageAtOffset(state, state.document_scroll_y); + const start: f64 = @floatFromInt(state.page_starts[anchor_page]); + const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page])); + anchor_fraction = std.math.clamp( + (state.document_scroll_y - start) / height, + 0, + 1, + ); + } + + var fresh = try @This().open(gpa, state.path, state.page + 1); + errdefer fresh.deinit(gpa); + if (state.search_query.len > 0) + fresh.search_query = try gpa.dupe(u8, state.search_query); + + fresh.fit = state.fit; + fresh.tint = state.tint; + fresh.pan_x = state.pan_x; + fresh.pan_y = state.pan_y; + fresh.text_scroll = state.text_scroll; + fresh.text_scroll_clamp_pending = true; + fresh.search_hit = state.search_hit; + fresh.highlights_dirty = fresh.search_query.len > 0; + fresh.search_reveal_pending = state.search_reveal_pending; + fresh.reveal_viewport_w = state.reveal_viewport_w; + fresh.reveal_viewport_h = state.reveal_viewport_h; + fresh.reveal_fit = state.reveal_fit; + fresh.reveal_viewport_valid = state.reveal_viewport_valid; + // Revisions are part of the backend texture key. Resetting this counter + // while the pane serial stays live can alias a cached pre-reload page. + fresh.next_raster_revision = state.next_raster_revision; + fresh.sections_output = state.sections_output; + if (preserve_anchor) { + fresh.scroll_to_page_pending = false; + fresh.layout_anchor_pending = true; + fresh.layout_anchor_page = anchor_page; + fresh.layout_anchor_fraction = anchor_fraction; + } + + var old = state.*; + state.* = fresh; + old.deinit(gpa); + } + + pub fn invalidateRaster(state: *@This(), page: usize) void { + if (rasterForPage(state, page)) |raster| raster.tried = false; + } + + pub fn invalidateAllRasters(state: *@This()) void { + for (state.rasters[0..state.rasters_len]) |*raster| { + if (raster.valid) raster.tried = false; + } + } + + fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void { + if (rgba.len == 0) return; + if (state.spare_len == state.spare.len) return gpa.free(rgba); + state.spare[state.spare_len] = rgba; + state.spare_len += 1; + } + + fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { + state.retireRgba(gpa, raster.rgba); + raster.* = .{}; + } + + fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 { + for (state.spare[0..state.spare_len], 0..) |candidate, index| { + if (candidate.len != bytes) continue; + state.spare_len -= 1; + state.spare[index] = state.spare[state.spare_len]; + return candidate; + } + return gpa.alloc(u8, bytes) catch null; + } + + fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void { + while (state.spare_len > 1) { + state.spare_len -= 1; + gpa.free(state.spare[state.spare_len]); + } + } + + fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void { + if (state.search_results) |*results| results.deinit(gpa); + state.search_results = null; + } + + fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void { + if (state.selection) |*selection| selection.deinit(gpa); + state.selection = null; + if (state.selection_text.len > 0) gpa.free(state.selection_text); + state.selection_text = &.{}; + state.selection_anchor = null; + state.selection_head = null; + } + + pub fn setSelection( + state: *@This(), + gpa: std.mem.Allocator, + start: Point, + end: Point, + invalidate_raster: bool, + ) SelectionUpdate { + if (state.selection != null and + state.selection_anchor != null and state.selection_head != null and + state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and + state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged; + var selection = state.document.select(gpa, state.page, start, end) catch return .failed; + const text = state.document.copySelection( + gpa, + state.page, + selection.start, + selection.end, + ) catch { + selection.deinit(gpa); + return .failed; + }; + + state.dropSelection(gpa); + state.selection = selection; + state.selection_text = text; + state.selection_anchor = start; + state.selection_head = end; + if (invalidate_raster) state.invalidateRaster(state.page); + return .changed; + } + + pub fn clearDrag(state: *@This()) void { + state.drag_anchor = null; + state.drag_head = null; + } + + pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void { + const changed = state.selection != null or state.selection_text.len > 0; + state.dropSelection(gpa); + if (changed) state.invalidateRaster(state.page); + } + + pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void { + state.clearDrag(); + state.clearSelection(gpa); + if (state.search_query.len == 0) return; + state.dropSearchQuery(gpa); + state.invalidateRaster(state.page); + } + + fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void { + state.dropSearchResults(gpa); + state.dropSelection(gpa); + state.clearDrag(); + if (state.text.len > 0) gpa.free(state.text); + state.text = &.{}; + state.text_tried = false; + state.text_scroll = 0; + state.text_scroll_clamp_pending = false; + state.highlights_dirty = state.search_query.len > 0; + state.search_reveal_pending = state.search_query.len > 0; + state.search_hit = 0; + } + + fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void { + if (state.search_query.len > 0) gpa.free(state.search_query); + state.search_query = &.{}; + state.search_hit = 0; + state.dropSearchResults(gpa); + state.highlights_dirty = false; + state.search_reveal_pending = false; + } + + pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void { + if (std.mem.eql(u8, state.search_query, query)) return; + const owned = try gpa.dupe(u8, query); + state.dropSearchQuery(gpa); + state.search_query = owned; + state.highlights_dirty = query.len > 0; + state.search_reveal_pending = query.len > 0; + state.invalidateRaster(state.page); + } + + pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 { + if (!state.text_tried) { + state.text_tried = true; + state.text = state.document.pageText(gpa, state.page) catch &.{}; + } + if (state.text_scroll_clamp_pending) { + const lines = std.mem.count(u8, state.text, "\n") + 1; + state.text_scroll = @min(state.text_scroll, lines - 1); + state.text_scroll_clamp_pending = false; + } + return state.text; + } + + pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void { + if (!state.highlights_dirty) return; + state.highlights_dirty = false; + state.dropSearchResults(gpa); + if (state.search_query.len == 0) return; + const results = state.document.search(gpa, state.page, state.search_query) catch return; + state.search_hit = if (results.hit_count == 0) + 0 + else + @min(state.search_hit, results.hit_count - 1); + state.search_results = results; + } + + pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline { + if (!state.outline_tried) { + state.outline_tried = true; + state.outline = state.document.outline(gpa) catch null; + } + return if (state.outline) |*outline| outline else null; + } + + pub fn renderSections( + state: *@This(), + pdf_gpa: std.mem.Allocator, + output_gpa: std.mem.Allocator, + ) ![]u8 { + const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline| + outline.entries + else + &.{}; + return SectionRows.render(output_gpa, state.path, entries); + } + + pub fn sectionDestination( + state: *@This(), + gpa: std.mem.Allocator, + ordinal: usize, + ) ?pdf.OutlineDestination { + const outline = state.ensureOutline(gpa) orelse return null; + return SectionRows.resolve(outline.entries, ordinal); + } + + /// Apply the one-based page/hit location encoded in a PDF search row. + /// Returns whether host pane cursor chrome must be reset. + pub fn focusLocation( + state: *@This(), + gpa: std.mem.Allocator, + line: usize, + column: usize, + ) bool { + const changed = line > 0 and state.activatePage(gpa, line - 1, true); + if (column > 0 and state.search_query.len > 0) { + state.search_hit = column - 1; + state.search_reveal_pending = true; + } + return changed; + } + + /// Change the document page while leaving pane cursor/selection chrome to + /// the UI adapter. Returns whether that pane-local chrome must be reset. + pub fn activatePage( + state: *@This(), + gpa: std.mem.Allocator, + page: usize, + reveal: bool, + ) bool { + state.outline_reveal_pending = null; + const next = @min(page, state.page_count -| 1); + const changed = next != state.page; + if (changed) { + state.invalidatePage(gpa); + state.page = next; + } + if (reveal) { + state.scroll_to_page_pending = true; + if (state.layout_valid) { + state.document_scroll_y = @floatFromInt(state.page_starts[next]); + state.scroll_to_page_pending = false; + } + } else { + state.search_reveal_pending = false; + } + return changed; + } + + pub fn toggleFit(state: *@This()) void { + state.fit = if (state.fit == .width) .height else .width; + state.pan_x = 0; + state.pan_y = 0; + state.layout_valid = false; + state.scroll_to_page_pending = true; + state.search_reveal_pending = state.search_query.len > 0; + } + + pub fn toggleTint(state: *@This()) void { + state.tint = state.tint.next(); + state.invalidateAllRasters(); + } + + pub fn queueOutlineReveal( + state: *@This(), + destination: pdf.OutlineInternalDestination, + ) void { + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + state.outline_reveal_pending = destination; + } + + pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { + gpa.free(state.path); + for (state.rasters[0..state.rasters_len]) |raster| + if (raster.rgba.len > 0) gpa.free(raster.rgba); + for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); + gpa.free(state.page_sizes); + gpa.free(state.page_starts); + gpa.free(state.page_heights); + if (state.text.len > 0) gpa.free(state.text); + if (state.search_query.len > 0) gpa.free(state.search_query); + if (state.search_results) |*results| results.deinit(gpa); + if (state.selection) |*selection| selection.deinit(gpa); + if (state.selection_text.len > 0) gpa.free(state.selection_text); + if (state.outline) |*outline| outline.deinit(gpa); + state.document.deinit(); + state.* = undefined; + } + } else void; + + pub const SearchOutput = struct { + bytes: usize = 0, + rows: usize = 0, + anchor: ?usize = null, + }; + + pub fn textLines( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + ) ![]const []const u8 { + if (comptime !enabled) return &.{}; + const page_text = state.ensureText(gpa); + const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1); + var it = std.mem.splitScalar(u8, page_text, '\n'); + var n: usize = 0; + while (it.next()) |line| : (n += 1) lines[n] = line; + return lines; + } + + pub fn visibleText( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + max_rows: usize, + ) ![]const u8 { + if (comptime !enabled) return ""; + const page_text = state.ensureText(gpa); + var start: usize = 0; + for (0..state.text_scroll) |_| { + const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse + return arena.dupe(u8, ""); + start = newline + 1; + } + if (max_rows == 0) return arena.dupe(u8, ""); + + var end = start; + var row: usize = 0; + while (row < max_rows) : (row += 1) { + const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse { + end = page_text.len; + break; + }; + if (row + 1 == max_rows) { + end = newline; + break; + } + end = newline + 1; + } + return arena.dupe(u8, page_text[start..end]); + } + + /// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The + /// same hit numbering drives persistent highlights and later reveal actions. + pub fn searchRows( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + pattern: []const u8, + from_cursor: bool, + out: []u8, + ) !SearchOutput { + if (comptime !enabled) return .{}; + try state.setSearchQuery(gpa, pattern); + const shown = std.fs.path.basename(state.path); + var result: SearchOutput = .{}; + const max_hits = 512; + var snippet_len = @min(pattern.len, 200); + while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80) + snippet_len -= 1; + const snippet = pattern[0..snippet_len]; + for (0..state.page_count) |page| { + if (result.rows >= max_hits) break; + var found = try state.document.search(gpa, page, pattern); + defer found.deinit(gpa); + + const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: { + const selection = state.selection orelse break :cursor null; + for (found.quads) |item| { + const q = item.quad; + const center: Point = .{ + .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, + .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, + }; + if (selection.contains(center)) break :cursor item.hit; + } + break :cursor null; + } else null; + + for (0..found.hit_count) |hit_index| { + if (result.rows >= max_hits) break; + const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + shown, page + 1, hit_index + 1, snippet, + }); + if (line.len > out.len - result.bytes) return result; + if (from_cursor and + (page < state.page or + (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?))) + result.anchor = result.rows; + @memcpy(out[result.bytes..][0..line.len], line); + result.bytes += line.len; + result.rows += 1; + } + } + return result; + } + + pub const Viewport = struct { pixel_w: u32, pixel_h: u32 }; + pub const VisiblePages = struct { first: usize = 0, len: usize = 0 }; + pub const PanAxis = enum { horizontal, vertical }; + pub const PanResult = enum { moved, edge, unavailable }; + pub const ScrollResult = struct { active_page: usize }; + pub const CellPixels = struct { w: u16, h: u16 }; + pub const NormalHost = enum { + none, + leader, + command_line, + search, + search_forward, + search_backward, + }; + pub const NormalResult = struct { + host: NormalHost = .none, + page_changed: bool = false, + }; + + const PlacedGeometry = struct { + geometry: image.NativeGeometry, + pixel_offset_y: f32, + }; + + pub const PlacedRaster = if (enabled) struct { + page: usize, + revision: u32, + fit: FitMode, + pan_x: u16, + geometry: image.NativeGeometry, + pixel_offset_y: f32, + rgba: []const u8, + width: usize, + band_height: usize, + } else void; + + const VisibleRows = struct { + base: image.NativeGeometry, + y0: u32, + y1: u32, + dst_y: u32, + dst_h: u32, + pixel_offset_y: f32, + }; + + pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { + if (comptime !enabled) return; + return .{ + .dpi = policy.dpi, + .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, + .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, + .max_dimension = policy.max_dimension, + }; + } + + fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 { + if (comptime !enabled) return 0; + if (fit == .height) return viewport.pixel_h; + const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) * + @as(f64, size.height) / @as(f64, size.width); + return @max(1, @as(u32, @intFromFloat(@min( + @as(f64, @floatFromInt(std.math.maxInt(u32))), + @round(scaled), + )))); + } + + fn pageAtOffset(state: *const State, offset: f64) usize { + if (comptime !enabled) return 0; + const y: u64 = @intFromFloat(std.math.clamp( + @floor(offset), + 0, + @as(f64, @floatFromInt(state.document_height -| 1)), + )); + var lo: usize = 0; + var hi: usize = state.page_count; + while (lo + 1 < hi) { + const mid = lo + (hi - lo) / 2; + if (state.page_starts[mid] <= y) lo = mid else hi = mid; + } + const end = state.page_starts[lo] + state.page_heights[lo]; + return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo; + } + + fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool { + if (comptime !enabled) return false; + const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; + const bottom = top + @as(f64, @floatFromInt(state.page_heights[page])); + return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h)); + } + + pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages { + if (comptime !enabled) return .{}; + var out: VisiblePages = .{}; + var page = pageAtOffset(state, state.document_scroll_y); + if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1; + out.first = page; + while (page < state.page_count) : (page += 1) { + const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; + if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break; + if (pageVisible(state, page, viewport)) out.len += 1; + } + return out; + } + + fn visibleContains(visible: VisiblePages, page: usize) bool { + return page >= visible.first and page - visible.first < visible.len; + } + + pub fn rasterForPage(state: *State, page: usize) ?*Raster { + if (comptime !enabled) return null; + for (state.rasters[0..state.rasters_len]) |*raster| + if (raster.valid and raster.page == page) return raster; + return null; + } + + fn rasterForPageConst(state: *const State, page: usize) ?*const Raster { + if (comptime !enabled) return null; + for (state.rasters[0..state.rasters_len]) |*raster| + if (raster.valid and raster.page == page) return raster; + return null; + } + + fn visibleRows( + state: *const State, + viewport: Viewport, + page: usize, + iw: usize, + ih: usize, + ) ?VisibleRows { + if (comptime !enabled) return null; + const page_h = state.page_heights[page]; + const base = image.nativeGeometry( + iw, + ih, + viewport.pixel_w, + page_h, + switch (state.fit) { + .width => .width, + .height => .height, + }, + state.pan_x, + 0, + ) orelse return null; + if (base.dst.h == 0 or base.src.h == 0) return null; + + const scroll_floor = @floor(state.document_scroll_y); + const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor); + const scroll_i: i64 = @intFromFloat(@min( + scroll_floor, + @as(f64, @floatFromInt(std.math.maxInt(i64))), + )); + const start_i: i64 = @intCast(@min( + state.page_starts[page], + @as(u64, std.math.maxInt(i64)), + )); + const full_y = start_i - scroll_i + @as(i64, base.dst.y); + const full_bottom = full_y + @as(i64, base.dst.h); + const visible_y = @max(@as(i64, 0), full_y); + const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom); + if (visible_bottom <= visible_y) return null; + + const rel_y0: u64 = @intCast(visible_y - full_y); + const rel_y1: u64 = @intCast(visible_bottom - full_y); + const src_y0: u32 = base.src.y + @as(u32, @intCast( + rel_y0 * base.src.h / base.dst.h, + )); + const src_y1: u32 = base.src.y + @as(u32, @intCast(@min( + @as(u64, base.src.h), + (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h, + ))); + if (src_y1 <= src_y0) return null; + return .{ + .base = base, + .y0 = src_y0, + .y1 = src_y1, + .dst_y = @intCast(visible_y), + .dst_h = @intCast(visible_bottom - visible_y), + .pixel_offset_y = -fractional, + }; + } + + fn placedGeometry( + state: *const State, + raster: *const Raster, + viewport: Viewport, + page: usize, + ) ?PlacedGeometry { + if (comptime !enabled) return null; + const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null; + const band_y: u32 = @intCast(raster.band_y); + const band_end: u32 = @intCast(raster.band_y + raster.band_h); + if (rows.y0 < band_y or rows.y1 > band_end) return null; + return .{ + .geometry = .{ + .src = .{ + .x = rows.base.src.x, + .y = rows.y0 - band_y, + .w = rows.base.src.w, + .h = rows.y1 - rows.y0, + }, + .dst = .{ + .x = rows.base.dst.x, + .y = rows.dst_y, + .w = rows.base.dst.w, + .h = rows.dst_h, + }, + }, + .pixel_offset_y = rows.pixel_offset_y, + }; + } + + pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, page) orelse return null; + if (raster.rgba.len == 0) return null; + const placed = placedGeometry(state, raster, viewport, page) orelse return null; + return .{ + .page = page, + .revision = raster.revision, + .fit = state.fit, + .pan_x = state.pan_x, + .geometry = placed.geometry, + .pixel_offset_y = placed.pixel_offset_y, + .rgba = raster.rgba, + .width = raster.iw, + .band_height = raster.band_h, + }; + } + + pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, state.page) orelse return null; + const placed = placedGeometry(state, raster, viewport, state.page) orelse return null; + return placed.geometry; + } + + pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize { + if (comptime !enabled) return null; + if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null; + const document_y = state.document_scroll_y + local_y; + const page = pageAtOffset(state, document_y); + const start: f64 = @floatFromInt(state.page_starts[page]); + if (document_y < start or + document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null; + return page; + } + + pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool { + if (comptime !enabled) return false; + return placedRaster(state, viewport, page) != null; + } + + pub fn nativeReady(state: *const State, viewport: Viewport) bool { + if (comptime !enabled) return false; + for (state.rasters[0..state.rasters_len]) |*raster| { + if (raster.valid and raster.rgba.len > 0 and + placedGeometry(state, raster, viewport, raster.page) != null) return true; + } + return false; + } + + pub fn pointAtPage( + state: *const State, + viewport: Viewport, + page: usize, + px: i64, + py: i64, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const raster = rasterForPageConst(state, page) orelse return null; + if (raster.rgba.len == 0) return null; + const placed = placedGeometry(state, raster, viewport, page) orelse return null; + return pointAtGeometry( + placed.geometry, + placed.pixel_offset_y, + raster.iw, + raster.ih, + raster.band_y, + px, + py, + clamp_to_page, + ); + } + + fn slotShape(slot: *const Raster) pdf.Raster { + const stride = slot.iw * 4; + return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih }; + } + + fn flinging(state: *const State, viewport: Viewport) bool { + if (comptime !enabled) return false; + return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); + } + + fn wantedBand( + state: *const State, + viewport: Viewport, + page: usize, + shape: pdf.Raster, + is_flinging: bool, + ) pdf.Raster.Band { + if (!is_flinging) return shape.wholePage(); + const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse + return shape.wholePage(); + const first = (@as(usize, rows.y0) / band_grain) * band_grain; + const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch + return shape.wholePage(); + return shape.band(first, last * band_grain - first); + } + + fn reconcile( + state: *State, + gpa: std.mem.Allocator, + request: RenderRequest, + tint_key: TintKey, + highlights: Highlights, + visible: VisiblePages, + viewport: Viewport, + ) void { + if (comptime !enabled) return; + const tz = tracy.zone(@src(), "pdf.reconcile"); + defer tz.end(); + + // Remove first so arriving pages can claim departing page buffers. Only + // the final visible set can be seen, so a fling skips crossed-over pages. + var index: usize = 0; + while (index < state.rasters_len) { + if (visibleContains(visible, state.rasters[index].page)) { + index += 1; + continue; + } + state.retireRaster(gpa, &state.rasters[index]); + state.rasters_len -= 1; + if (index != state.rasters_len) + state.rasters[index] = state.rasters[state.rasters_len]; + } + + const is_flinging = flinging(state, viewport); + var page = visible.first; + const end = visible.first + visible.len; + while (page < end) : (page += 1) { + var raster = rasterForPage(state, page); + if (raster == null) { + if (state.rasters_len == state.rasters.len) continue; + state.rasters[state.rasters_len] = .{ .valid = true, .page = page }; + state.rasters_len += 1; + raster = &state.rasters[state.rasters_len - 1]; + } + const slot = raster.?; + const page_highlights = highlights.forPage(page, state.page); + const decorated = page_highlights.len > 0; + const stale = !slot.tried or !slot.request_valid or + !slot.request.eql(request) or slot.decorated != decorated or + slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or + slot.rgba.len == 0 or slot.band_h == 0; + const uncovered = !stale and uncovered: { + const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); + break :uncovered slot.band_y > want.y or + slot.band_y + slot.band_h < want.y + want.height; + }; + if (!stale and !uncovered) continue; + + slot.tried = true; + slot.request = request; + slot.request_valid = true; + const shape_or_null = shape: { + const tz_measure = tracy.zone(@src(), "pdf.measure"); + defer tz_measure.end(); + break :shape state.document.measureRenderAt(page, request) catch null; + }; + const shape = shape_or_null orelse continue; + const want = wantedBand(state, viewport, page, shape, is_flinging); + const fresh = state.claimRgba(gpa, want.len) orelse continue; + const filled = filled: { + { + const tz_render = tracy.zone(@src(), "pdf.render_into"); + defer tz_render.end(); + state.document.renderIntoAt( + page, + request, + shape, + want, + page_highlights, + fresh, + ) catch break :filled false; + } + const tz_tint = tracy.zone(@src(), "pdf.tint"); + defer tz_tint.end(); + pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch + break :filled false; + break :filled true; + }; + if (!filled) { + state.retireRgba(gpa, fresh); + continue; + } + + state.retireRgba(gpa, slot.rgba); + slot.rgba = fresh; + slot.iw = shape.width; + slot.ih = shape.height; + slot.band_y = want.y; + slot.band_h = want.height; + slot.decorated = decorated; + slot.tint_key = tint_key; + state.next_raster_revision +%= 1; + if (state.next_raster_revision == 0) state.next_raster_revision = 1; + slot.revision = state.next_raster_revision; + } + state.trimSpares(gpa); + } + + pub fn renderFrame( + state: *State, + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + viewport: Viewport, + policy: RasterPolicy, + tint_key: TintKey, + highlight_input: HighlightInput, + ) VisiblePages { + if (comptime !enabled) return .{}; + ensureLayout(state, viewport); + state.resolveSearch(gpa); + const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{ + .items = &.{}, + .active_start = 0, + .hover_page = null, + }; + const request = renderRequest(viewport, policy); + var visible = visiblePages(state, viewport); + reconcile(state, gpa, request, tint_key, highlights, visible, viewport); + + rearmSearchReveal(state, viewport); + revealSearch(state, viewport, activeGeometry(state, viewport)); + visible = visiblePages(state, viewport); + reconcile(state, gpa, request, tint_key, highlights, visible, viewport); + return visible; + } + + pub fn normalizedPixel(value: f32, dimension: usize) u32 { + const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension)); + return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); + } + + pub fn scaledStep(base: u32, count: u32) u32 { + return @intCast(@min( + @as(u64, std.math.maxInt(u32)), + @as(u64, base) * @max(@as(u64, 1), count), + )); + } + + pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult { + if (comptime !enabled) return null; + if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll); + if (next == state.document_scroll_y) return null; + state.scroll_travel += @abs(next - state.document_scroll_y); + state.document_scroll_y = next; + return .{ .active_page = pageAtOffset(state, next) }; + } + + pub fn panPixels( + state: *State, + geometry: image.NativeGeometry, + axis: PanAxis, + direction: i8, + display_pixels: u32, + ) PanResult { + if (comptime !enabled) return .unavailable; + const raster = rasterForPage(state, state.page) orelse return .unavailable; + const source_full: u32 = @intCast(switch (axis) { + .horizontal => raster.iw, + .vertical => raster.ih, + }); + const crop = switch (axis) { + .horizontal => geometry.src.w, + .vertical => geometry.src.h, + }; + const source_at = switch (axis) { + .horizontal => geometry.src.x, + .vertical => geometry.src.y, + }; + const displayed = @max(@as(u32, 1), switch (axis) { + .horizontal => geometry.dst.w, + .vertical => geometry.dst.h, + }); + const overflow = source_full -| crop; + if (overflow == 0 or + (direction < 0 and source_at == 0) or + (direction > 0 and source_at >= overflow)) return .edge; + + const source_step = @max( + @as(u64, 1), + (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, + ); + const normalized_step: u32 = @intCast(@min( + @as(u64, std.math.maxInt(u16)), + @max( + @as(u64, 1), + (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, + ), + )); + const position = switch (axis) { + .horizontal => &state.pan_x, + .vertical => &state.pan_y, + }; + if (direction > 0) { + position.* = @intCast(@min( + @as(u32, std.math.maxInt(u16)), + @as(u32, position.*) + normalized_step, + )); + } else { + position.* -|= @intCast(normalized_step); + } + return .moved; + } + + fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void { + if (comptime !enabled) return; + const raster = rasterForPage(state, state.page) orelse return; + if (raster.iw <= geometry.src.w) return; + state.pan_x = if (end) std.math.maxInt(u16) else 0; + } + + fn rearmSearchReveal(state: *State, viewport: Viewport) void { + if (comptime !enabled) return; + if (state.search_query.len == 0) return; + if (!state.reveal_viewport_valid or + state.reveal_viewport_w != viewport.pixel_w or + state.reveal_viewport_h != viewport.pixel_h or + state.reveal_fit != state.fit) + state.search_reveal_pending = true; + } + + pub fn revealSearch( + state: *State, + viewport: Viewport, + geometry: ?image.NativeGeometry, + ) void { + if (comptime !enabled) return; + if (!state.search_reveal_pending) return; + state.reveal_viewport_w = viewport.pixel_w; + state.reveal_viewport_h = viewport.pixel_h; + state.reveal_fit = state.fit; + state.reveal_viewport_valid = true; + const results = state.search_results orelse { + state.search_reveal_pending = false; + return; + }; + if (results.hit_count == 0 or results.quads.len == 0) { + state.search_reveal_pending = false; + return; + } + state.search_hit = @min(state.search_hit, results.hit_count - 1); + const q = for (results.quads) |item| { + if (item.hit == state.search_hit) break item.quad; + } else { + state.search_reveal_pending = false; + return; + }; + state.search_reveal_pending = false; + const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; + const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; + if (state.fit == .width) { + ensureLayout(state, viewport); + const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) + + @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page])); + const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll); + return; + } + const placed = geometry orelse return; + const raster = rasterForPage(state, state.page) orelse return; + const full: u32 = @intCast(raster.iw); + const at = normalizedPixel(center_x, raster.iw); + if (at >= placed.src.x and at < placed.src.x + placed.src.w) return; + const overflow = full -| placed.src.w; + if (overflow == 0) return; + const wanted = @min(overflow, at -| placed.src.w / 2); + state.pan_x = @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + } + + pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool { + const current: i64 = @intCast(state.page); + const last: i64 = @intCast(state.page_count -| 1); + return state.activatePage( + gpa, + @intCast(std.math.clamp(current + delta, 0, last)), + true, + ); + } + + fn scrollNormal( + state: *State, + gpa: std.mem.Allocator, + viewport: ?Viewport, + delta_pixels: f64, + ) bool { + const view = viewport orelse return false; + ensureLayout(state, view); + const result = scrollDocument(state, view, delta_pixels) orelse return false; + return result.active_page != state.page and + state.activatePage(gpa, result.active_page, false); + } + + fn moveRows( + state: *State, + gpa: std.mem.Allocator, + native_images: bool, + viewport: ?Viewport, + cell_pixels: CellPixels, + direction: i8, + count: u32, + ) bool { + if (!native_images) { + const pages: i64 = @intCast(@max(@as(u32, 1), count)); + return stepPage(state, gpa, if (direction > 0) pages else -pages); + } + return scrollNormal( + state, + gpa, + viewport, + @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction, + ); + } + + fn movePage( + state: *State, + gpa: std.mem.Allocator, + native_images: bool, + viewport: ?Viewport, + cell_pixels: CellPixels, + direction: i8, + kind: modal.Normal.Page, + count: u32, + ) bool { + if (!native_images) { + const pages: i64 = @intCast(@max(@as(u32, 1), count)); + return stepPage(state, gpa, if (direction > 0) pages else -pages); + } + const base: u32 = switch (kind) { + .half_down, .half_up => if (viewport) |view| + @max(@as(u32, 1), view.pixel_h / 2) + else + cell_pixels.h, + .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h, + }; + return scrollNormal( + state, + gpa, + viewport, + @as(f64, @floatFromInt(scaledStep(base, count))) * direction, + ); + } + + pub fn applyNormal( + state: *State, + gpa: std.mem.Allocator, + semantic: modal.Normal.Action, + native_images: bool, + cell_pixels: CellPixels, + viewport: ?Viewport, + geometry: ?image.NativeGeometry, + ) NormalResult { + if (comptime !enabled) return .{}; + var result: NormalResult = .{}; + switch (semantic) { + .escape => state.cancelChrome(gpa), + .move => |move| switch (move.motion) { + .down => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + move.count, + ), + .up => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + move.count, + ), + .left => if (native_images) if (geometry) |placed| { + _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count)); + }, + .right => if (native_images) if (geometry) |placed| { + _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count)); + }, + else => {}, + }, + .goto => |go| switch (go.target) { + .file_start => result.page_changed = state.activatePage( + gpa, + if (go.explicit_count) go.count -| 1 else 0, + true, + ), + .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true), + .line_start, .first_nonws => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, false), + .line_end => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, true), + .line_down => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + go.count, + ), + .line_up => result.page_changed = moveRows( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + go.count, + ), + else => {}, + }, + .line => |line| switch (line) { + .start, .first_nonws => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, false), + .end => if (native_images) if (geometry) |placed| + setHorizontalEdge(state, placed, true), + }, + .goto_line => |go| { + if (go.explicit) + result.page_changed = state.activatePage(gpa, go.count -| 1, true); + }, + .page => |page| result.page_changed = switch (page.kind) { + .half_down, .down => movePage( + state, + gpa, + native_images, + viewport, + cell_pixels, + 1, + page.kind, + page.count, + ), + .half_up, .up => movePage( + state, + gpa, + native_images, + viewport, + cell_pixels, + -1, + page.kind, + page.count, + ), + }, + .view => |view| result.page_changed = switch (view) { + .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1), + .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1), + else => false, + }, + .leader => result.host = .leader, + .command_line => result.host = .command_line, + .search => result.host = .search, + .search_step => |direction| result.host = if (direction == .forward) + .search_forward + else + .search_backward, + else => {}, + } + return result; + } + + pub fn captureLayoutAnchor(state: *State) void { + if (comptime !enabled) return; + if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return; + const page = pageAtOffset(state, state.document_scroll_y); + const start: f64 = @floatFromInt(state.page_starts[page]); + const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page])); + state.layout_anchor_page = page; + state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1); + state.layout_anchor_pending = true; + } + + fn consumeOutlineReveal(state: *State, viewport: Viewport) void { + const destination = state.outline_reveal_pending orelse return; + state.outline_reveal_pending = null; + const page = @min(destination.page, state.page_count -| 1); + const size = state.page_sizes[page]; + const raw_y = destination.y orelse 0; + const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + + @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page])); + + if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) { + const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) * + @as(f64, size.width) / @as(f64, size.height); + const viewport_width: f64 = @floatFromInt(viewport.pixel_w); + if (display_width > viewport_width) { + const x = std.math.clamp(raw_x, 0, size.width); + const target = @as(f64, x) / @as(f64, size.width) * display_width; + const overflow = display_width - viewport_width; + const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow); + state.pan_x = @intFromFloat(@round( + wanted / overflow * @as(f64, std.math.maxInt(u16)), + )); + } + }; + state.search_reveal_pending = false; + state.reveal_viewport_w = viewport.pixel_w; + state.reveal_viewport_h = viewport.pixel_h; + state.reveal_fit = state.fit; + state.reveal_viewport_valid = true; + } + + pub fn ensureLayout(state: *State, viewport: Viewport) void { + if (comptime !enabled) return; + const tz = tracy.zone(@src(), "pdf.ensure_layout"); + defer tz.end(); + if (state.layout_valid and !state.scroll_to_page_pending and + !state.layout_anchor_pending and + (state.layout_viewport_w != viewport.pixel_w or + state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state); + if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or + state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit) + { + var at: u64 = 0; + for (state.page_sizes, 0..) |size, page| { + state.page_starts[page] = at; + const height = pageHeight(size, viewport, state.fit); + state.page_heights[page] = height; + at = std.math.add(u64, at, height) catch std.math.maxInt(u64); + if (page + 1 < state.page_count) + at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64); + } + state.document_height = at; + state.layout_viewport_w = viewport.pixel_w; + state.layout_viewport_h = viewport.pixel_h; + state.layout_fit = state.fit; + state.layout_valid = true; + if (state.scroll_to_page_pending) { + state.document_scroll_y = @floatFromInt(state.page_starts[state.page]); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + } else if (state.layout_anchor_pending) { + const page = @min(state.layout_anchor_page, state.page_count -| 1); + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + + state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); + state.layout_anchor_pending = false; + } + } + consumeOutlineReveal(state, viewport); + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll); + } + + pub const WordProbe = if (enabled) struct { + page: usize, + quads: []pdf.Quad, + text: []u8, + + pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { + gpa.free(probe.quads); + gpa.free(probe.text); + probe.* = undefined; + } + } else void; + + pub const PointerDrag = if (enabled) struct { + native: bool = false, + /// Right-button Look probes this cell on release without borrowing or + /// replacing the pane's persistent MuPDF selection. + word_at: ?struct { col: u16, row: u16 } = null, + /// Drag updates keep selection geometry/text live, but their expensive + /// baked raster highlight is committed once on release. + selection_changed: bool = false, + } else struct {}; + + pub const PointerAction = enum { look, exec }; + + pub const PointerRelease = if (enabled) struct { + action: ?PointerAction = null, + text: []const u8 = &.{}, + probe: ?WordProbe = null, + + pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void { + if (release.probe) |*probe| probe.deinit(gpa); + release.* = undefined; + } + } else struct { + pub fn deinit(_: *@This(), _: std.mem.Allocator) void {} + }; + + pub fn probeWord( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + point: if (enabled) pdf.Point else void, + ) !?WordProbe { + if (comptime !enabled) return null; + var selection = try document.select(gpa, page, point, point); + errdefer selection.deinit(gpa); + const text = try document.copySelection(gpa, page, selection.start, selection.end); + errdefer gpa.free(text); + if (selection.quads.len == 0 or text.len == 0) { + selection.deinit(gpa); + gpa.free(text); + return null; + } + // Transfer the quad allocation out of Selection. There is intentionally + // no Selection.deinit on this success path: WordProbe is now its owner. + return .{ .page = page, .quads = selection.quads, .text = text }; + } + + pub fn pointAtGeometry( + geometry: image.NativeGeometry, + pixel_offset_y: f32, + full_width: usize, + full_height: usize, + band_y: usize, + pixel_x: i64, + pixel_y: i64, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + if (full_width == 0 or full_height == 0 or + geometry.src.w == 0 or geometry.src.h == 0 or + geometry.dst.w == 0 or geometry.dst.h == 0) return null; + + const left: f64 = @floatFromInt(geometry.dst.x); + const top: f64 = @floatFromInt(geometry.dst.y); + const right = left + @as(f64, @floatFromInt(geometry.dst.w)); + const bottom = top + @as(f64, @floatFromInt(geometry.dst.h)); + var x: f64 = @floatFromInt(pixel_x); + var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y); + if (clamp_to_page) { + // Half-open rectangles: keep a clamped point infinitesimally inside + // the last presented pixel instead of letting it become `right`. + const epsilon = 1.0 / 1024.0; + x = std.math.clamp(x, left, @max(left, right - epsilon)); + y = std.math.clamp(y, top, @max(top, bottom - epsilon)); + } else if (x < left or x >= right or y < top or y >= bottom) { + return null; + } + + const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) + + (x - left) * @as(f64, @floatFromInt(geometry.src.w)) / + @as(f64, @floatFromInt(geometry.dst.w)); + const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) + + (y - top) * @as(f64, @floatFromInt(geometry.src.h)) / + @as(f64, @floatFromInt(geometry.dst.h)); + const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f)))); + const band_source_y: usize = @intFromFloat(@floor(source_y_f)); + const source_y = @min(full_height - 1, band_y + band_source_y); + return .{ + .x = (@as(f32, @floatFromInt(source_x)) + 0.5) / + @as(f32, @floatFromInt(full_width)), + .y = (@as(f32, @floatFromInt(source_y)) + 0.5) / + @as(f32, @floatFromInt(full_height)), + }; + } + + pub fn openPane( + core: *pardes.Pardes, + id: usize, + path: []const u8, + page_one_based: usize, + ) !*pardes.Pane { + if (comptime !enabled) return error.PdfDisabled; + var state = if (filesystem.localPath(path) != null) + try State.open(core.pdf_gpa, path, page_one_based) + else virtual: { + const bytes = try filesystem.read(core, path); + defer core.gpa.free(bytes); + break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based); + }; + errdefer state.deinit(core.pdf_gpa); + const pane = try core.newDocPane(id); + pane.pdf = state; + pane.cur_pinned = true; + if (filesystem.localPath(path) != null) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); + return pane; + } + + /// Release a PDF payload while its pane slot is still installed, so the host + /// can retire the corresponding directory watch before that id is reused. + pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void { + if (comptime !enabled) return; + for (core.panes, 0..) |slot, id| { + if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + } + state.deinit(core.pdf_gpa); + } + + pub fn reloadPane( + core: *pardes.Pardes, + pane: *pardes.Pane, + ) !void { + if (comptime !enabled) return error.PdfDisabled; + const state = &(pane.pdf orelse return error.MissingPdfState); + try state.reload(core.pdf_gpa); + resetPageChrome(pane); + } + + pub fn isSectionsOutput(pane: *const pardes.Pane) bool { + if (comptime !enabled) return false; + const file = pane.file orelse return false; + const output = file.output orelse return false; + return switch (output.from) { + .cmd => |builtin| builtin == .PdfSections, + else => false, + }; + } + + fn refreshCleanSections(core: *pardes.Pardes, state: *State) void { + if (comptime !enabled) return; + const remembered = state.sections_output orelse return; + if (remembered.pane >= core.panes.len) return; + const result = core.panes[remembered.pane] orelse return; + if (result.serial != remembered.serial or !isSectionsOutput(result)) return; + const file = &result.file.?; + if (file.revision != remembered.revision) return; + + const content = state.renderSections(core.pdf_gpa, core.gpa) catch { + state.sections_output = null; + return; + }; + if (std.mem.eql(u8, file.content, content)) { + core.gpa.free(content); + return; + } + core.invalidateLookHover(remembered.pane); + File.setContent(core, file, content); + const rows = File.lineCount(file.content); + file.scroll = @min(file.scroll, rows - 1); + const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1); + result.cur_row = @intCast(row); + result.cur_col = @intCast(@min( + @as(usize, @intCast(@max(0, result.cur_col))), + modal.lineSlice(file.content, row).len, + )); + result.msel.active = false; + result.vsel.active = false; + result.nsel = 0; + state.sections_output.?.revision = file.revision; + } + + /// One successful watched-path transaction, including every piece of derived + /// PDF output. The caller owns generic update bookkeeping and status text. + pub fn reloadWatched( + core: *pardes.Pardes, + pane: *pardes.Pane, + ) !void { + try reloadPane(core, pane); + refreshCleanSections(core, &pane.pdf.?); + } + + fn rearmCleanSections( + core: *pardes.Pardes, + id: usize, + pane: *pardes.Pane, + state: *State, + ) bool { + const remembered = state.sections_output orelse return false; + if (remembered.pane >= core.panes.len) return false; + const result = core.panes[remembered.pane] orelse return false; + if (result.serial != remembered.serial or !isSectionsOutput(result)) return false; + const file = &result.file.?; + if (file.revision != remembered.revision) return false; + + file.scroll = 0; + result.cur_row = 0; + result.cur_col = 0; + result.msel.active = false; + result.vsel.active = false; + result.nsel = 0; + pane.search_pane = remembered.pane; + pane.search_row = null; + core.armLookWalk(remembered.pane); + core.active = id; + return true; + } + + /// Lazily materialise this pane's cached outline as a location list. All + /// PDF-specific ownership stays here; Pardes contributes only placement. + pub fn openSections(core: *pardes.Pardes, id: usize) void { + if (comptime !enabled) return; + const pane = core.panes[id] orelse return; + const state = &(pane.pdf orelse return); + if (rearmCleanSections(core, id, pane, state)) return; + const content = state.renderSections(core.pdf_gpa, core.gpa) catch return; + + const free = core.freeSlot() orelse { + core.gpa.free(content); + return; + }; + const dir = std.fs.path.dirname(state.path) orelse "/"; + const result = Output.open( + core, + free, + dir, + .{ .cmd = .PdfSections }, + "", + content, + ) catch { + core.gpa.free(content); + return; + }; + state.sections_output = .{ + .pane = free, + .serial = result.serial, + .revision = result.file.?.revision, + }; + core.placeDoc(id, free, result); + layout.compute(core); + core.active = id; + pane.search_pane = free; + pane.search_row = null; + core.armLookWalk(free); + } + + const SectionsOwner = struct { + id: usize, + pane: *pardes.Pane, + state: *State, + }; + + fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner { + const output = core.panes[output_id] orelse return null; + if (!isSectionsOutput(output)) return null; + const file = output.file.?; + for (core.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const state = if (pane.pdf) |*pdf_state| pdf_state else continue; + const token = state.sections_output orelse continue; + if (token.pane == output_id and token.serial == output.serial and + token.revision == file.revision) + return .{ .id = id, .pane = pane, .state = state }; + } + return null; + } + + pub fn lookSection( + core: *pardes.Pardes, + output_id: usize, + path: []const u8, + at: look.Spot, + ) bool { + if (comptime !enabled) return false; + const output = core.panes[output_id] orelse return false; + if (!isSectionsOutput(output)) return false; + if (at.line == 0 or at.col == 0 or at.end_line != 0 or + !look.isPdfPath(path)) return false; + const owner = sectionsOwner(core, output_id) orelse return true; + + var joined_path: [4096]u8 = undefined; + const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/"; + const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/"; + const target_path = if (std.fs.path.isAbsolute(path)) + path + else + std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true; + if (!std.mem.eql(u8, owner.state.path, target_path)) return true; + + const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true; + switch (destination) { + .internal => |internal| revealOutlineDestination(core, owner.pane, internal), + .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }), + .none => unreachable, + } + core.active = owner.id; + return true; + } + + pub fn resetPageChrome(pane: *pardes.Pane) void { + pane.cur_row = 0; + pane.cur_col = 0; + pane.vsel.active = false; + pane.msel.active = false; + pane.nsel = 0; + } + + pub fn activatePage( + core: *pardes.Pardes, + pane: *pardes.Pane, + page: usize, + reveal: bool, + ) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane); + } + + pub fn revealOutlineDestination( + core: *pardes.Pardes, + pane: *pardes.Pane, + destination: OutlineInternalDestination, + ) void { + if (comptime !enabled) return; + activatePage(core, pane, destination.page, false); + const state = &pane.pdf.?; + state.queueOutlineReveal(destination); + // Consume immediately when geometry already exists; otherwise the PDF + // draw pass consumes the same value after the next layout transaction. + _ = ensurePaneLayout(core, pane); + } + + pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void { + activatePage(core, pane, page, true); + } + + pub fn toggleFit(pane: *pardes.Pane) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + state.toggleFit(); + } + + pub fn toggleTint(pane: *pardes.Pane) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + state.toggleTint(); + } + + pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void { + if (comptime !enabled) return; + const state = &(pane.pdf orelse return); + if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane); + } + + pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport { + if (comptime !enabled) return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const cols = rect.w -| config.GUTTER; + const rows = rect.h -| pardes.BOX_H; + if (cols == 0 or rows == 0) return null; + return .{ + .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), + .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h), + }; + } + + pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport { + if (comptime !enabled) return null; + const state = &(pane.pdf orelse return null); + const view = paneViewport(core, pane) orelse return null; + ensureLayout(state, view); + return view; + } + + pub fn tintColors(core: *const pardes.Pardes) TintColors { + if (comptime !enabled) return; + const theme = core.theme(); + return .{ + .background = theme.bg orelse theme.tag_bg, + .foreground = theme.fg orelse theme.tag_fg, + }; + } + + pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry { + if (comptime !enabled) return null; + const state = if (pane.pdf) |*view| view else return null; + const view = paneViewport(core, pane) orelse return null; + return activeGeometry(state, view); + } + + pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize { + if (comptime !enabled) return null; + const state = pane.pdf orelse return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; + const local_y = @as(f64, @floatFromInt( + @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, + )); + return pageAtViewportY(&state, local_y); + } + + pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool { + if (comptime !enabled) return false; + if (!core.native_images) return false; + const state = if (pane.pdf) |*view| view else return false; + const view = paneViewport(core, pane) orelse return false; + return nativeReady(state, view); + } + + pub fn nativePageAtGridRow( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + row: u16, + ) ?usize { + if (comptime !enabled) return null; + if (!core.native_images) return null; + const state = if (pane.pdf) |*view| view else return null; + const page = pageAtGridRow(core, pane, row) orelse return null; + const view = paneViewport(core, pane) orelse return null; + if (!pageReady(state, view, page)) return null; + return page; + } + + pub fn pointAt( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + col: u16, + row: u16, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const state = pane.pdf orelse return null; + return panePointAtPage(core, pane, state.page, col, row, clamp_to_page); + } + + pub fn panePointAtPage( + core: *const pardes.Pardes, + pane: *const pardes.Pane, + page: usize, + col: u16, + row: u16, + clamp_to_page: bool, + ) ?Point { + if (comptime !enabled) return null; + const state = if (pane.pdf) |*view| view else return null; + const view = paneViewport(core, pane) orelse return null; + const rect = for (core.panes, 0..) |slot, id| { + if (slot == pane) break core.rects[id]; + } else return null; + const body_x = @as(i64, rect.x + config.GUTTER); + const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H); + const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; + const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; + return pointAtPage(state, view, page, px, py, clamp_to_page); + } + + pub fn probeAt( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) ?WordProbe { + if (comptime !enabled) return null; + const page = nativePageAtGridRow(core, pane, row) orelse return null; + const point = panePointAtPage(core, pane, page, col, row, false) orelse return null; + const state = &(pane.pdf orelse return null); + return probeWord(&state.document, core.pdf_gpa, page, point) catch null; + } + + pub fn beginDrag( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + snap_word: bool, + ) bool { + if (comptime !enabled) return false; + const hit_page = pageAtGridRow(core, pane, row) orelse return false; + if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false); + const state = &pane.pdf.?; + state.clearDrag(); + const point = pointAt(core, pane, col, row, false) orelse return false; + state.drag_anchor = point; + state.drag_head = point; + if (!snap_word) return true; + if (state.selection) |selection| if (selection.contains(point)) return true; + if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) { + // Preserve the old selection transactionally, but never let an + // outside click execute its stale text. + state.clearDrag(); + return false; + } + return true; + } + + pub fn beginSelection( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) bool { + return beginDrag(core, pane, col, row, true); + } + + pub fn updateSelection( + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + invalidate_raster: bool, + ) SelectionUpdate { + if (comptime !enabled) return .failed; + const state = &(pane.pdf orelse return .failed); + const anchor = state.drag_anchor orelse return .failed; + const point = pointAt(core, pane, col, row, true) orelse return .failed; + if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary; + const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster); + if (result != .failed) state.drag_head = point; + return result; + } + + pub fn pointerStart( + core: *pardes.Pardes, + pane: *pardes.Pane, + button: pardes.Mouse.Button, + col: u16, + row: u16, + on_tag: bool, + ) PointerDrag { + if (comptime !enabled) return .{}; + if (on_tag or !paneNativeReady(core, pane)) return .{}; + var drag: PointerDrag = .{ .native = true }; + if (button == config.look_button) { + drag.word_at = .{ .col = col, .row = row }; + } else if (button == config.select_button) { + _ = beginDrag(core, pane, col, row, false); + } else if (button == config.exec_button) { + _ = beginDrag(core, pane, col, row, true); + } + return drag; + } + + pub fn pointerUpdate( + drag: *PointerDrag, + core: *pardes.Pardes, + pane: *pardes.Pane, + col: u16, + row: u16, + ) void { + if (comptime !enabled) return; + if (!drag.native) return; + if (drag.word_at) |at| { + if (col == at.col and row == at.row) return; + if (!beginDrag(core, pane, at.col, at.row, false)) { + drag.word_at = null; + return; + } + switch (updateSelection(core, pane, col, row, false)) { + .failed => { + drag.word_at = null; + pane.pdf.?.clearDrag(); + }, + // Adjacent grid cells can still address one raster pixel. Keep + // click mode and retry farther out. + .stationary => {}, + .unchanged => drag.word_at = null, + .changed => { + drag.word_at = null; + drag.selection_changed = true; + }, + } + return; + } + switch (updateSelection(core, pane, col, row, false)) { + .failed => pane.pdf.?.clearDrag(), + .stationary, .unchanged => {}, + .changed => drag.selection_changed = true, + } + } + + pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void { + if (comptime !enabled) return; + if (drag.native) if (pane.pdf) |*state| { + if (drag.selection_changed) state.invalidateRaster(state.page); + state.clearDrag(); + }; + } + + pub fn pointerRelease( + core: *pardes.Pardes, + pane: *pardes.Pane, + drag: PointerDrag, + button: pardes.Mouse.Button, + chorded: bool, + ) PointerRelease { + if (comptime !enabled) return .{}; + const state = &(pane.pdf orelse return .{}); + const slot = @intFromEnum(button); + const grid_selection = pane.sel[slot]; + pane.sel[slot].state = .none; // native quads, not projected text cells + + // Drag updates changed live geometry/text while leaving baked pixels + // stable. Commit exactly once before any chord/Exec/Look can clear state. + if (drag.selection_changed) state.invalidateRaster(state.page); + if (chorded) { + state.clearDrag(); + return .{}; + } + if (drag.word_at) |at| { + const maybe_probe = probeAt(core, pane, at.col, at.row); + state.clearDrag(); + const probe = maybe_probe orelse return .{}; + // A neighboring visible page becomes current before Look dispatch. + // WordProbe owns its text/quads independently of that state change. + if (probe.page != state.page) activatePage(core, pane, probe.page, false); + const text = probe.text; + return .{ .action = .look, .text = text, .probe = probe }; + } + if (button == config.select_button) { + const dragged = grid_selection.c0 != grid_selection.c1 or + grid_selection.r0 != grid_selection.r1; + if (!dragged) state.clearSelection(core.pdf_gpa); + pane.cur_pinned = true; + pane.mode = .normal; + pane.msel.active = false; + pane.vsel.active = false; + pane.normal.clear(); + pane.nsel = 0; + state.clearDrag(); + return .{}; + } + if (state.drag_anchor == null or state.selection_text.len == 0) { + state.clearDrag(); + return .{}; + } + const text = state.selection_text; + state.clearDrag(); + return .{ + .action = if (button == config.look_button) .look else .exec, + .text = text, + }; + } + + pub fn highlightInput( + core: *pardes.Pardes, + pane_id: usize, + pane: *const pardes.Pane, + ) HighlightInput { + if (comptime !enabled) return; + const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview| + if (preview.pane == pane_id and preview.serial == pane.serial) + preview.probe.quads + else + &.{} + else + &.{}; + const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null; + const selection_color = core.theme().sel_bg; + return .{ + .hover_quads = hover_quads, + .hover_page = hover_page, + .hover_color = selection_color, + .selection_color = selection_color, + }; + } + + pub fn panPane( + core: *pardes.Pardes, + pane: *pardes.Pane, + axis: PanAxis, + direction: i8, + display_pixels: u32, + ) PanResult { + if (comptime !enabled) return .unavailable; + const placed = paneGeometry(core, pane) orelse return .unavailable; + const state = &(pane.pdf orelse return .unavailable); + return panPixels(state, placed, axis, direction, display_pixels); + } + + pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool { + if (comptime !enabled) return false; + const tz = tracy.zone(@src(), "pdf.scroll_notch"); + defer tz.end(); + const state = &(pane.pdf orelse return false); + const view = ensurePaneLayout(core, pane) orelse return false; + const result = scrollDocument(state, view, delta_pixels) orelse return false; + if (result.active_page != state.page) activatePage(core, pane, result.active_page, false); + return true; + } + + pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { + if (comptime !enabled) return; + if (!core.native_images) return stepPanePage(core, pane, direction); + const rows: u32 = @intCast(@max(1, config.wheel_rows)); + const pixels = scaledStep(core.cell_pixels.h, rows); + _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction); + } + + pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { + if (comptime !enabled) return; + const state = pane.pdf orelse return; + if (!core.native_images or state.fit != .height) return; + const cols: u32 = @intCast(@max(1, config.wheel_cols)); + _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols)); + } + + pub fn draw( + core: *pardes.Pardes, + pane: *pardes.Pane, + rect: pardes.Rect, + pane_id: usize, + text_x: u16, + text_width: u16, + ) bool { + if (comptime !enabled) return false; + if (!core.native_images or rect.h <= pardes.BOX_H) return false; + const state = &(pane.pdf orelse return false); + const view = paneViewport(core, pane) orelse return false; + const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; + const visible = renderFrame( + state, + core.pdf_gpa, + core.scratch.allocator(), + view, + pardes.pdf_raster_policy, + key, + highlightInput(core, pane_id, pane), + ); + var placed_any = false; + var page = visible.first; + const visible_end = visible.first + visible.len; + while (page < visible_end) : (page += 1) { + const placed = placedRaster(state, view, page) orelse continue; + if (!core.appendImagePlace(.{ + .pane = @intCast(pane_id), + .serial = pane.serial, + .native = .{ + .revision = placed.revision, + .page = @intCast(placed.page), + .fit = switch (placed.fit) { + .width => .width, + .height => .height, + }, + .pan_x = placed.pan_x, + .geometry = placed.geometry, + .pixel_offset_y = placed.pixel_offset_y, + }, + .x = text_x, + .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, + .w = text_width, + .h = rect.h - pardes.BOX_H, + .rgba = placed.rgba, + .iw = placed.width, + // The texture contains the retained band, not the full page. + .ih = placed.band_height, + })) break; + placed_any = true; + } + if (!placed_any) return false; + + // This frame has spent the motion used to choose its raster band. + state.scroll_travel = 0; + const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const chrome = core.chromeTheme(); + const theme = core.theme(); + const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; + core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); + core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); + + const track_h: usize = rect.h - pardes.BOX_H; + const total = @max(@as(u64, 1), state.document_height); + const len = @max( + @as(usize, 1), + @as(usize, @intCast(@min( + @as(u64, track_h), + @as(u64, track_h) * view.pixel_h / total, + ))), + ); + const offset: u64 = @intFromFloat(@floor(state.document_scroll_y)); + const pos: usize = @intCast(@min( + @as(u64, track_h -| 1), + @as(u64, track_h) * offset / total, + )); + var y = pos; + while (y < track_h and y < pos + len) : (y += 1) + core.surface.fill( + rect.x, + body_y + @as(u16, @intCast(y)), + 1, + 1, + .{ .bg = .{ .rgb = chrome.scroll_thumb } }, + ); + return true; + } + + pub const SectionRows = if (enabled) struct { + pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination { + return switch (destination) { + .internal => destination, + .external => |uri| if (safeHttpUri(uri)) destination else null, + .none => null, + }; + } + + fn safeHttpUri(uri: []const u8) bool { + // Effect.open_link is inline and cannot carry a larger URL. Omitting + // it here is preferable to rendering a row which can never act. + if (uri.len > 256) return false; + var has_scheme = false; + for (config.url_schemes) |scheme| { + if (std.mem.startsWith(u8, uri, scheme)) { + has_scheme = true; + break; + } + } + if (!has_scheme) return false; + for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false; + return true; + } + + pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination { + if (ordinal >= entries.len) return null; + const entry = entries[ordinal]; + if (entry.destination != .none) return usableDestination(entry.destination); + var i = ordinal + 1; + while (i < entries.len and entries[i].depth > entry.depth) : (i += 1) + if (usableDestination(entries[i].destination)) |destination| return destination; + return null; + } + + pub fn resolveOrdinals( + gpa: std.mem.Allocator, + entries: []const pdf.OutlineEntry, + ) ![]usize { + const unresolved = std.math.maxInt(usize); + const ordinals = try gpa.alloc(usize, entries.len); + @memset(ordinals, unresolved); + + const Pending = struct { depth: u8, ordinal: usize }; + var pending: [256]Pending = undefined; + var pending_len: usize = 0; + for (entries, 0..) |entry, ordinal| { + while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth) + pending_len -= 1; + + if (usableDestination(entry.destination) != null) { + ordinals[ordinal] = ordinal; + for (pending[0..pending_len]) |ancestor| + ordinals[ancestor.ordinal] = ordinal; + pending_len = 0; + } else if (entry.destination == .none) { + pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal }; + pending_len += 1; + } + } + return ordinals; + } + + fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize { + const raw = title orelse { + if (out) |buf| @memcpy(buf[0..10], "[untitled]"); + return 10; + }; + var at: usize = 0; + var i: usize = 0; + var wrote = false; + var pending_space = false; + while (i < raw.len) { + const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch { + pending_space = wrote; + i += 1; + continue; + }; + if (i + n > raw.len) { + pending_space = wrote; + break; + } + const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch { + pending_space = wrote; + i += n; + continue; + }; + const whitespace_or_control = cp <= 0x20 or cp == 0x7f or + (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029; + if (whitespace_or_control) { + pending_space = wrote; + } else { + if (pending_space) { + if (out) |buf| buf[at] = ' '; + at += 1; + } + if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]); + at += n; + wrote = true; + pending_space = false; + } + i += n; + } + if (!wrote) { + if (out) |buf| @memcpy(buf[0..13], "[empty title]"); + return 13; + } + return at; + } + + pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 { + const target = std.fs.path.basename(path); + const ordinals = try resolveOrdinals(gpa, entries); + defer gpa.free(ordinals); + var total: usize = 0; + for (entries, 0..) |entry, ordinal| { + const resolved = ordinals[ordinal]; + if (resolved == std.math.maxInt(usize)) continue; + const destination = usableDestination(entries[resolved].destination) orelse unreachable; + total += switch (destination) { + .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| std.fmt.count("{s} ", .{uri}), + .none => unreachable, + }; + total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1; + } + const out = try gpa.alloc(u8, total); + errdefer gpa.free(out); + var at: usize = 0; + for (entries, 0..) |entry, ordinal| { + const resolved = ordinals[ordinal]; + if (resolved == std.math.maxInt(usize)) continue; + const destination = usableDestination(entries[resolved].destination) orelse unreachable; + const prefix = switch (destination) { + .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}), + .none => unreachable, + }; + at += prefix.len; + const indent = @as(usize, entry.depth) * 2; + @memset(out[at..][0..indent], ' '); + at += indent; + at += cleanTitle(out[at..], entry.title); + out[at] = '\n'; + at += 1; + } + return out; + } + } else struct {}; +}; + +pub const Terminal = struct { + pub const enabled = pardes.terminal_panes; + const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; + + pub const history_max = if (enabled) 64 else 8; + + pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; + pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; + + pub const Replay = struct { + bytes: [1024 * 1024]u8 = undefined, + head: usize = 0, + len: usize = 0, + }; + + pub const State = struct { + vt: VtSlot, + stream: StreamSlot, + replay: Replay, + reply: [256]u8 = undefined, + reply_len: u16 = 0, + }; + + const GColor = ghostty_vt.color; + + const FilterPaletteKey = struct { + bg: GColor.RGB, + fg: GColor.RGB, + base: [16]GColor.RGB, + }; + + pub const FilterPalette = if (enabled) LivePalette else struct {}; + + const LivePalette = struct { + key: ?FilterPaletteKey = null, + colors: GColor.Palette = GColor.default, + + fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { + const bg = asGhostRgb(theme.bg orelse theme.tag_bg); + const fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var base: [16]GColor.RGB = undefined; + if (theme.palette) |palette| { + for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); + } else { + const synthesized = [16][3]u8{ + theme.bg orelse theme.tag_bg, + theme.kw, + theme.str, + theme.num, + theme.box, + theme.sel_bg, + theme.comment, + theme.fg orelse theme.tag_fg, + theme.lineno, + theme.kw, + theme.str, + theme.num, + theme.scroll_thumb, + theme.sel_fg, + theme.tag_fg, + theme.fg orelse theme.tag_fg, + }; + for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); + } + + const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; + if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; + + var seed = GColor.default; + for (base, 0..) |rgb, i| seed[i] = rgb; + self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); + self.key = key; + return &self.colors; + } + }; + + fn asGhostRgb(rgb: [3]u8) GColor.RGB { + return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; + } + + fn asPardesColor(rgb: GColor.RGB) pardes.Color { + return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + } + + /// The owned text standing in for `rows` live terminal rows, beginning at + /// absolute surface row `row`. The emulator grid remains untouched underneath. + pub const EditBuffer = struct { + row: i32 = 0, + rows: i32 = 1, + text: []u8 = &.{}, + }; + + /// One whole-state terminal edit boundary. Null means the pane has not yet + /// materialized an edit buffer; non-null snapshots own their text. + pub const Snapshot = struct { + ovl: ?EditBuffer, + cur_row: i32, + cur_col: i32, + vsel: Pane.CharSel, + }; + + pub const PendingCommand = struct { + bytes: []u8 = &.{}, + wait: enum { none, spawn, input } = .none, + }; + + pub fn armShellSpawn(pane: *Pane) void { + if (comptime !enabled) return; + std.debug.assert(pane.pending_command.bytes.len == 0); + pane.pending_command.wait = .spawn; + } + + pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { + if (pane.pending_command.wait == .none) return false; + const old_len = pane.pending_command.bytes.len; + const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); + const bytes = if (old_len == 0) + try pane.gpa.alloc(u8, new_len) + else + try pane.gpa.realloc(pane.pending_command.bytes, new_len); + @memcpy(bytes[old_len..][0..command.len], command); + bytes[new_len - 1] = '\r'; + pane.pending_command.bytes = bytes; + pane.greet = false; + return true; + } + + pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; + if (prompt_marks) { + pane.pending_command.wait = .input; + releasePendingCommand(p, id, pane, false); + } else { + pane.greet = false; + releasePendingCommand(p, id, pane, true); + } + } + + pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { + if (pane.pending_command.wait == .input) + releasePendingCommand(p, id, pane, promptInputReady(pane)); + } + + fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { + if (!ready) return; + const bytes = pane.pending_command.bytes; + pane.pending_command = .{}; + if (bytes.len > 0) { + p.emitWrite(id, bytes); + pane.gpa.free(bytes); + } + } + + pub fn deinitPendingCommand(pane: *Pane) void { + if (pane.pending_command.bytes.len > 0) + pane.gpa.free(pane.pending_command.bytes); + pane.pending_command = .{}; + } + + pub fn terminalIo() std.Io { + return if (comptime !pardes.hosted) + std.Io.failing + else + std.Io.Threaded.global_single_threaded.io(); + } + + /// The three numbers ghostty's scrollbar reports; all zero without an emulator. + pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; + + pub fn scrollbar(pane: *const Pane) Scrollbar { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const sb = state.vt.screens.active.pages.scrollbar(); + return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; + } + + /// The emulator's viewport offset, in SHELL rows: the top of what it shows. + pub fn gridOffset(pane: *const Pane) i32 { + return @intCast(scrollbar(pane).offset); + } + + /// Where the emulator itself puts the cursor, in active-area cells — the origin + /// without one, which is where an empty pane's cursor belongs anyway. + pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; + + pub fn gridCursor(pane: *const Pane) GridCursor { + if (comptime !enabled) return .{}; + const state = pane.terminal orelse return .{}; + const cur = state.vt.screens.active.cursor; + return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; + } + + pub fn visibleCursor(pane: *const Pane) ?GridCursor { + if (comptime !enabled) return null; + const state = pane.terminal orelse return null; + if (!state.vt.modes.get(.cursor_visible)) return null; + const cur = state.vt.screens.active.cursor; + const sb = scrollbar(pane); + const row = sb.total - sb.len + cur.y; + if (row < sb.offset or row - sb.offset >= sb.len) return null; + return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) }; + } + + /// Move the emulator's viewport by `delta` shell rows (negative scrolls back). + pub fn scrollGrid(pane: *Pane, delta: i32) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.{ .delta_row = delta }); + } + + /// Snap the viewport back onto live output. + pub fn followOutput(pane: *Pane) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.screens.active.scroll(.active); + } + + /// Reflow the grid. A failed reflow keeps the grid it had rather than dropping + /// a scrollback; the next resize retries with the same numbers. + pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; + } + + /// DECSET 2004: the program wants its pastes bracketed. + pub fn bracketedPaste(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.bracketed_paste); + } + + /// The program tracks the mouse itself, so a click in its body is its event. + pub fn reportsMouse(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const m = &state.vt.modes; + return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); + } + + /// ...and wants them in SGR (1006) rather than the legacy X10 bytes. + pub fn mouseFormatSgr(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.mouse_format_sgr); + } + + /// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. + pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { + if (comptime !enabled) return &.{}; + const state = pane.terminal orelse return &.{}; + return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); + } + + /// Release the emulator's heap. The Pane allocation itself is the core's. + pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + state.stream.deinit(); + state.vt.deinit(gpa); + gpa.destroy(state); + pane.terminal = null; + } + + /// Allocate the live emulator half of a terminal pane. Slot ownership, serial + /// assignment, and spawn effects remain core lifecycle invariants. + pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try createDoc(gpa, cols, rows); + errdefer gpa.destroy(pane); + if (comptime !enabled) return pane; + const state = try gpa.create(State); + errdefer gpa.destroy(state); + state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ + .cols = cols, + .rows = rows, + .max_scrollback = 16 * 1024 * 1024, + }); + state.stream = state.vt.vtStream(); + state.replay.head = 0; + state.replay.len = 0; + state.reply_len = 0; + state.stream.handler.effects.write_pty = ptyReport; + state.stream.handler.effects.device_attributes = ptyDeviceAttrs; + pane.terminal = state; + pane.tty_filter = true; + return pane; + } + + pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { + const pane = try gpa.create(Pane); + pane.* = .{ + .gpa = gpa, + .cols = cols, + .rows = rows, + }; + return pane; + } + + /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy + /// stay with the core; raw VT replay and viewport restoration belong here. + pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { + const terminal = src.terminal.?; + if (comptime !enabled) { + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + errdefer p.gpa.destroy(pane); + if (terminal.stream.len > 0) + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; + return pane; + } + const bytes = if (terminal.stream_b64.len > 0) + try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) + else + &.{}; + const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); + if (bytes.len > 0) { + ingest(pane, bytes); + followOutput(pane); + if (src.scroll > 0) + scrollGrid(pane, -@as(i32, @intCast(src.scroll))); + } + return pane; + } + + /// Feed the emulator and retain the bounded suffix a dump can replay. Live + /// output and restoration share this byte path, then apply different views. + fn ingest(pane: *Pane, bytes: []const u8) void { + const state = pane.terminal.?; + const replay = &state.replay; + if (bytes.len >= replay.bytes.len) { + const kept = bytes[bytes.len - replay.bytes.len ..]; + @memcpy(&replay.bytes, kept); + replay.head = 0; + replay.len = replay.bytes.len; + } else { + const overflow = bytes.len -| (replay.bytes.len - replay.len); + replay.head = (replay.head + overflow) % replay.bytes.len; + replay.len -= overflow; + const tail = (replay.head + replay.len) % replay.bytes.len; + const first = @min(bytes.len, replay.bytes.len - tail); + @memcpy(replay.bytes[tail..][0..first], bytes[0..first]); + @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]); + replay.len += bytes.len; + } + state.stream.nextSlice(bytes); + } + + /// Return the replay ring in chronological order. Wrapped records are copied + /// into `allocator`; contiguous records remain a borrowed slice of the pane. + fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + const state = pane.terminal orelse return &.{}; + const replay = &state.replay; + if (replay.len == 0) return &.{}; + if (replay.head + replay.len <= replay.bytes.len) + return replay.bytes[replay.head..][0..replay.len]; + const out = try allocator.alloc(u8, replay.len); + const first = replay.bytes.len - replay.head; + @memcpy(out[0..first], replay.bytes[replay.head..]); + @memcpy(out[first..], replay.bytes[0 .. replay.len - first]); + return out; + } + + test "replay ownership is terminal-only and construction rolls back on allocation failure" { + const Case = struct { + fn run(gpa: std.mem.Allocator, terminal: bool) !void { + const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqual(terminal and enabled, pane.terminal != null); + } + }; + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); + } + + test "document construction allocates only the pane and has inert terminal defaults" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + const allocator = failing.allocator(); + const pane = try createDoc(allocator, 40, 12); + defer allocator.destroy(pane); + defer deinitEmulator(pane, allocator); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(@as(usize, 1), failing.alloc_index); + try std.testing.expectEqual(@as(usize, 0), failing.resize_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expect(!bracketedPaste(pane)); + try std.testing.expect(!reportsMouse(pane)); + try std.testing.expect(!mouseFormatSgr(pane)); + try std.testing.expect(!promptInputReady(pane)); + scrollGrid(pane, 100); + followOutput(pane); + resizeGrid(pane, allocator, 80, 24); + try std.testing.expectEqual(GridCursor{}, gridCursor(pane)); + try std.testing.expectEqual(Scrollbar{}, scrollbar(pane)); + try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator)); + try std.testing.expectEqualStrings("", try replayBytes(pane, allocator)); + try std.testing.expect(!failing.has_induced_failure); + } + + test "terminal state keeps parser fragments and sends emulator replies through its owner" { + if (comptime !enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + const state = pane.terminal.?; + try std.testing.expect(state.stream.handler.terminal == &state.vt); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); + while (p.nextEffect()) |effect| { + if (effect == .write) return error.PrematureTerminalReply; + } + p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } }); + var replies: usize = 0; + while (p.nextEffect()) |effect| { + if (effect != .write) continue; + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice()); + replies += 1; + } + try std.testing.expectEqual(@as(usize, 1), replies); + try std.testing.expectEqual(@as(u16, 0), state.reply_len); + try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator)); + } + + test "replay keeps a bounded chronological suffix across large writes and wrapping" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const pane = try create(gpa, 40, 12); + defer gpa.destroy(pane); + defer deinitEmulator(pane, gpa); + try std.testing.expectEqualStrings("", try replayBytes(pane, gpa)); + ingest(pane, "hello"); + const small = try replayBytes(pane, gpa); + try std.testing.expectEqualStrings("hello", small); + try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr)); + + const capacity = pane.terminal.?.replay.bytes.len; + const input = try gpa.alloc(u8, capacity + 7); + defer gpa.free(input); + @memset(input, 0); + const ending = "\x1b[31mred\x1b[0m\r\n"; + @memcpy(input[input.len - ending.len ..], ending); + ingest(pane, input); + try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa)); + ingest(pane, "next\r\n"); + const wrapped = try replayBytes(pane, gpa); + defer gpa.free(wrapped); + try std.testing.expectEqual(capacity, wrapped.len); + try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]); + try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]); + } + + test "replay restores terminal cells from the retained stream" { + if (comptime !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(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n"); + const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa)); + defer gpa.free(encoded); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = pane.cols, + .rows = pane.rows, + .terminal = .{ .stream_b64 = encoded }, + }); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + const before = try screenTextAlloc(pane, gpa); + defer gpa.free(before); + const after = try screenTextAlloc(restored, gpa); + defer gpa.free(after); + try std.testing.expectEqualStrings(before, after); + try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); + } + + /// Record and parse one live pty read, invalidate its motion surface, and + /// follow it only when the body (possibly parked under a tag edit) is raw. + pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { + // There are no pty reads at all without an emulator to parse them into. + if (comptime !enabled) return; + if (pane.terminal == null) return; + const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or + pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or + pane.ed_undo_len > 0 or pane.ed_redo_len > 0; + if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); + p.shell_rows.markStale(pane); + const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (body_mode == .tty) followOutput(pane); + } + + const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; + const PositionPin = struct { + target: *i32, + row: RowPin, + overlay: ?*EditBuffer, + edit_row: ?i32 = null, + }; + + fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin { + if (row < 0) return .{ .offset = row }; + const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1); + var left = pages.total_rows - 1 - bounded; + var node = pages.pages.last.?; + while (left >= node.rows()) { + left -= node.rows(); + node = node.prev.?; + } + const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch { + failed.* = true; + return .{}; + }; + return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) }; + } + + fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 { + const pin = row.pin orelse return row.offset; + const alive = pages orelse return 0; + if (pin.garbage) return 0; + var after: usize = 0; + var node = alive.pages.last; + while (node) |item| : (node = item.prev) { + if (item == pin.node) + return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset; + after += item.rows(); + } + return 0; + } + + fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin { + var grid = target.*; + if (overlay) |edit| { + const lines: i32 = @intCast(modal.lineCount(edit.text)); + if (grid >= edit.row and grid < edit.row +| lines) + return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row }; + if (grid > edit.row) grid = grid -| lines +| edit.rows; + } + return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay }; + } + + fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { + const screens = &pane.terminal.?.vt.screens; + const key = screens.active_key; + const generation = screens.generation(key); + const pages = &screens.active.pages; + const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel }; + var groups: [history_max * 2 + 1]Group = undefined; + groups[0] = .{ + .overlay = if (pane.ovl) |*overlay| overlay else null, + .cursor = &pane.cur_row, + .selection = &pane.vsel, + }; + var ngroups: usize = 1; + for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| { + for (history) |*snapshot| { + groups[ngroups] = .{ + .overlay = if (snapshot.ovl) |*overlay| overlay else null, + .cursor = &snapshot.cur_row, + .selection = &snapshot.vsel, + }; + ngroups += 1; + } + } + const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin }; + var spans: [groups.len]Span = undefined; + var nspans: usize = 0; + var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined; + var npositions: usize = 0; + var failed = false; + for (groups[0..ngroups]) |group| { + if (group.overlay) |overlay| { + spans[nspans] = .{ + .overlay = overlay, + .start = trackRow(pages, overlay.row, &failed), + .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed), + }; + nspans += 1; + } + positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed); + npositions += 1; + if (group.selection.active) { + positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed); + npositions += 1; + } + } + const overlay = groups[0].overlay; + var extra: [Pane.max_selections * 2 + 4]*i32 = undefined; + var nextra: usize = 0; + if (pane.msel.active) { + extra[nextra] = &pane.msel.r0; + extra[nextra + 1] = &pane.msel.r1; + nextra += 2; + } + for (pane.sels[0..pane.nsel]) |*selection| { + extra[nextra] = &selection.row; + extra[nextra + 1] = &selection.arow; + nextra += 2; + } + if (pane.append_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + if (pane.look_at) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } + for (extra[0..nextra]) |target| { + positions[npositions] = trackPosition(pages, target, overlay, &failed); + npositions += 1; + } + ingest(pane, bytes); + // RIS can destroy the alternate screen and every pin it owned. + const alive = if (screens.generation(key) == generation) pages else null; + var moved = false; + for (spans[0..nspans]) |span| { + const start = pinnedRow(alive, span.start); + const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1); + moved = moved or start != span.overlay.row or rows != span.overlay.rows; + span.overlay.row = start; + span.overlay.rows = rows; + if (alive) |owner| { + if (span.start.pin) |pin| owner.untrackPin(pin); + if (span.end.pin) |pin| owner.untrackPin(pin); + } + } + for (positions[0..npositions]) |position| { + var row = pinnedRow(alive, position.row); + if (position.overlay) |edit| { + if (position.edit_row) |relative| { + row = edit.row +| relative; + } else if (row > edit.row) { + row = if (row < edit.row +| edit.rows) + edit.row + else + row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text))); + } + } + moved = moved or row != position.target.*; + position.target.* = row; + if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin); + } + if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text. + if (failed) { + const message = "terminal edit position reset: out of memory"; + @memcpy(pane.msg[0..message.len], message); + pane.msg_len = message.len; + } + } + + test "raw terminal output needs no position tracking allocations" { + if (comptime !enabled) return error.SkipZigTest; + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.mode = .tty; + feedOutput(p, pane, "warm"); + const allocations = failing.alloc_index; + failing.fail_index = allocations; + const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins(); + feedOutput(p, pane, " output"); + try std.testing.expectEqual(allocations, failing.alloc_index); + try std.testing.expect(!failing.has_induced_failure); + try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins()); + } + + pub fn promptInputReady(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.screens.active_key != .alternate and + state.vt.screens.active.cursor.semantic_content == .input; + } + + /// Encode one key for the program that owns a raw terminal and queue its pty + /// write. Global chords and mode routing have already been handled by core. + pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + var control: [1]u8 = undefined; + const bytes: ?[]const u8 = blk: { + if (key.ctrl) { + if (key.cp >= 'a' and key.cp <= 'z') { + control[0] = @intCast(key.cp - 0x60); + break :blk control[0..1]; + } + // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. + if (key.cp >= '@' and key.cp <= '_') { + control[0] = @intCast(key.cp - 0x40); + break :blk control[0..1]; + } + } + if (key.text.len > 0) break :blk key.text; + break :blk switch (key.cp) { + Key.enter => "\r", + Key.backspace => "\x7f", + Key.tab => "\t", + Key.escape => "\x1b", + Key.up => "\x1b[A", + Key.down => "\x1b[B", + Key.right => "\x1b[C", + Key.left => "\x1b[D", + Key.delete => "\x1b[3~", + else => null, + }; + }; + if (bytes) |encoded| + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); + } + + pub fn enterTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + if (comptime enabled) if (pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: { + const screen = pane.terminal.?.vt.screens.active; + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row = pane.gridRow(pane.cur_row) - goff; + if (vp_row < 0) break :handoff; + + var grid_col: i32 = @max(0, pane.cur_col); + if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { + if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { + .cut => |cols| grid_col += @intCast(cols), + .keep, .blank => {}, + }; + } + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, + }) orelse break :handoff; + const cursor_pin = screen.cursor.page_pin.*; + var prompts = cursor_pin.promptIterator(.left_up, null); + const prompt_pin = prompts.next() orelse break :handoff; + if (click_pin.before(prompt_pin)) break :handoff; + const moves = screen.promptClickMove(click_pin); + for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); + for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); + }; + + pane.mode = .tty; + pane.msel.active = false; + pane.vsel.active = false; + pane.nsel = 0; + // A pinned row scrolls away. Raw mode must follow the program's live + // cursor, and Last must not restore a stale modal spot on the way back. + pane.cur_pinned = false; + pane.select = false; + pane.append_at = null; + pane.sticky_col = -1; + pane.normal.clear(); + } + + // Returned slices remain valid until the next cache rebuild or reset. + pub const RowsCache = struct { + /// whose grid this describes; null = the slot is free + pane: ?*const Pane = null, + /// the rows joined by '\n' — `flatSurface` hands this back verbatim + /// instead of rebuilding the join on every keystroke + text: []const u8 = &.{}, + /// slices INTO `text`, absolute grid rows from 0 + rows: [][]const u8 = &.{}, + /// `text` is a prefix of this: blanking a prompt row shortens the join, + /// and the slack is not worth a second allocation to reclaim + text_alloc: []u8 = &.{}, + stale: bool = false, + + pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.text_alloc.len > 0) gpa.free(c.text_alloc); + if (c.rows.len > 0) gpa.free(c.rows); + c.* = .{}; + } + + /// Free a stale entry. Called at the top of `update`, and nowhere else. + pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { + if (c.stale) c.reset(gpa); + } + + /// `pane`'s grid moved: the entry no longer describes it. + pub fn markStale(c: *RowsCache, pane: *const Pane) void { + if (c.pane == pane) c.stale = true; + } + + pub fn dropPane(c: *RowsCache, pane: *const Pane) void { + if (c.pane != pane) return; + c.pane = null; + c.stale = true; + } + }; + + const Rows = struct { + text_alloc: []u8, + text: []const u8, + rows: [][]const u8, + }; + + const empty_grid = [1][]const u8{""}; + + /// What LEAVING raw tty mode does to one prompt row, decided from its cells + /// alone. See config.tty_blank for why any of this happens. + const PromptCut = union(enum) { + /// show the row exactly as ghostty dumped it + keep, + /// show nothing at all + blank, + /// drop this many leading COLUMNS — the prompt — and keep the rest, which + /// is what was typed at it + cut: usize, + }; + + fn promptCut(pin: ghostty_vt.Pin) PromptCut { + if (config.tty_blank == .prompt_and_input) return .blank; + const cells = pin.cells(.all); + var cols: usize = 0; + while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; + // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or + // a repaint that has moved on. Nothing here is the prompt, so hide nothing. + if (cols == 0) return .keep; + // ...and all prompt, nothing typed yet: the row is chrome end to end. + if (cols >= cells.len) return .blank; + return .{ .cut = cols }; + } + + fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { + const cols = switch (promptCut(pin)) { + .keep => return raw, + .blank => return "", + .cut => |n| n, + }; + const cells = pin.cells(.all); + var at: usize = 0; + var col: usize = 0; + while (col < cols and at < raw.len) { + const cell = &cells[col]; + at = @min(raw.len, at + dumpedBytes(pin, cell)); + // the tail cell of a wide glyph spells nothing of its own + col += if (cell.wide == .wide) @as(usize, 2) else 1; + } + return std.mem.trimEnd(u8, raw[at..], " \t"); + } + + fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { + switch (cell.wide) { + .spacer_head, .spacer_tail => return 0, + .narrow, .wide => {}, + } + var n: usize = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( + cell.codepoint(), + ) catch 1, + // A cell carrying only a colour still spells one blank in the dump. + else => 1, + }; + if (cell.content_tag == .codepoint_grapheme) { + if (pin.grapheme(cell)) |extra| for (extra) |cp| { + n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; + }; + } + return n; + } + + pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + if (comptime !enabled) return &empty_grid; + if (pane.terminal == null) return &empty_grid; + const c = &p.shell_rows; + if (!c.stale and c.pane == pane) return c.rows; + if (c.pane != null) { + c.stale = true; + return (try buildRows(p.scratch.allocator(), p, pane)).rows; + } + const built = try buildRows(p.gpa, p, pane); + c.* = .{ + .pane = pane, + .text = built.text, + .rows = built.rows, + .text_alloc = built.text_alloc, + }; + return c.rows; + } + + /// The full modal motion surface: shell history with the live edit overlay + /// spliced into the rows it covers. + pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const rows = try shellRows(p, pane); + if (pane.ovl == null) return rows; + var last = rows.len; + if (pane.ovl) |overlay| + last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); + var count = last; + if (pane.ovl) |overlay| { + if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) + count = count - @min( + @as(usize, @intCast(overlay.rows)), + last - @as(usize, @intCast(overlay.row)), + ) + @max(1, modal.lineCount(overlay.text)); + } + const lines = try p.scratch.allocator().alloc([]const u8, count); + var n: usize = 0; + var grid_row: usize = 0; + while (grid_row < last) : (grid_row += 1) { + if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { + var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); + while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; + grid_row += @intCast(overlay.rows - 1); + continue; + }; + lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; + n += 1; + } + return lines[0..n]; + } + + /// Flatten `cursorLines` without rebuilding the common cached/no-overlay + /// case. Scratch-owned when a join is required. + pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { + const cache = &p.shell_rows; + if (!cache.stale and cache.pane == pane and + lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; + var total: usize = if (lines.len > 0) lines.len - 1 else 0; + for (lines) |line| total += line.len; + const text = try p.scratch.allocator().alloc(u8, total); + var at: usize = 0; + for (lines, 0..) |line, i| { + if (i > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..line.len], line); + at += line.len; + } + return text; + } + + /// Materialize or extend the terminal edit overlay with one exact allocation. + /// Row slices are scratch-owned/borrowed; only the joined text is installed. + pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); + const extending = fresh or up > 0 or down > 0; + var row0 = old.row; + var covered = old.rows; + var text = old.text; + var owned = false; + if (extending) { + const rows = shellRows(p, pane) catch return null; + row0 = old.row - @max(0, up); + covered = old.rows + @max(0, up) + @max(0, down); + const up_len: usize = @intCast(@max(0, up)); + const down_len: usize = @intCast(@max(0, down)); + const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; + for (parts[0..up_len], 0..) |*part, i| { + const src = @as(usize, @intCast(row0)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + const middle: usize = @intCast(old.row); + parts[up_len] = if (fresh) + (if (middle < rows.len) rows[middle] else "") + else + old.text; + for (parts[up_len + 1 ..], 0..) |*part, i| { + const src = @as(usize, @intCast(old.row + old.rows)) + i; + part.* = if (src < rows.len) rows[src] else ""; + } + text = std.mem.join(p.gpa, "\n", parts) catch return null; + owned = true; + } + + const row: usize = @intCast(@max(0, want_lo - row0)); + const line_len: i32 = @intCast(modal.lineSlice(text, row).len); + if (col > line_len) { + const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { + if (owned) p.gpa.free(text); + return null; + }; + @memset(spaces, ' '); + const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { + if (owned) p.gpa.free(text); + return null; + }; + if (owned) p.gpa.free(text); + text = padded; + owned = true; + } + if (owned) { + if (pane.ovl) |overlay| p.gpa.free(overlay.text); + pane.ovl = .{ .row = row0, .rows = covered, .text = text }; + } + return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; + } + + /// Consume a rewritten overlay, freeing the terminal edit text it replaces. + pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + p.gpa.free(overlay.text); + overlay.text = new; + } + + /// Serialize terminal-only state; the core supplies shared pane metadata. + pub fn dumpPane( + pane: *Pane, + arena: std.mem.Allocator, + tag: []const u8, + body: []const u8, + scroll: usize, + ) !dump.Pane { + if (!enabled or pane.terminal == null) { + const text = if (pane.ovl) |overlay| overlay.text else ""; + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = try arena.dupe(u8, text), + .stream_b64 = &.{}, + .cursor = .{ .col = 0, .row = 0 }, + }, + }; + } + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + const extra = if (pane.ovl) |overlay| overlay.text.len else 0; + const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); + var len: usize = 0; + var lines = std.mem.splitAny(u8, full, "\n"); + var prompts = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var row: i32 = 0; + var skip: i32 = 0; + while (lines.next()) |raw| : (row += 1) { + // Hidden overlay rows still consume prompt pins to keep them aligned. + const prompt = if (pane.mode != .tty) prompts.next() else null; + if (skip > 0) { + skip -= 1; + continue; + } + if (row > 0) { + stream[len] = '\n'; + len += 1; + } + if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { + @memcpy(stream[len..][0..overlay.text.len], overlay.text); + len += overlay.text.len; + skip = overlay.rows - 1; + continue; + }; + const shown = if (prompt) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + @memcpy(stream[len..][0..shown.len], shown); + len += shown.len; + } + return .{ + .kind = .terminal, + .tag = tag, + .body = body, + .scroll = scroll, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .terminal = .{ + .cwd = try arena.dupe(u8, pane.cwdSlice()), + .stream = stream[0..len], + .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), + .cursor = .{ + .col = pane.terminal.?.vt.screens.active.cursor.x, + .row = pane.terminal.?.vt.screens.active.cursor.y, + }, + }, + }; + } + + fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { + const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); + var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + // split yields one more item than delimiters; the extra final slot is the + // cursor row retained below. + const n_rows = std.mem.count(u8, full, "\n") + 2; + const rows = try alloc.alloc([]const u8, n_rows); + errdefer alloc.free(rows); + // Blanking a prompt row only ever SHORTENS it and the retained cursor row + // adds one separator, so the dump's length plus one bounds the join. + const text = try alloc.alloc(u8, full.len + 1); + errdefer alloc.free(text); + var at: usize = 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, full, '\n'); + while (it.next()) |raw| { + const shown = if (pit.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw + else + raw; + if (n > 0) { + text[at] = '\n'; + at += 1; + } + @memcpy(text[at..][0..shown.len], shown); + rows[n] = text[at..][0..shown.len]; + at += shown.len; + n += 1; + } + text[at] = '\n'; + at += 1; + rows[n] = text[at..][0..0]; + n += 1; + std.debug.assert(n == n_rows); + return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; + } + + pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { + const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { + const state = pane.terminal orelse break :vp &.{}; + const screen = state.vt.screens.active; + var tl = screen.pages.getTopLeft(.viewport); + tl.x = 0; + const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; + var rows_out: std.Io.Writer.Allocating = .init(arena); + try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); + const raw = try rows_out.toOwnedSlice(); + var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); + var lines = std.mem.splitScalar(u8, raw, '\n'); + var n: usize = 0; + while (lines.next()) |ln| { + vp[n] = if (pane.mode != .tty) + if (prompts.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln + else + ln + else + ln; + n += 1; + } + std.debug.assert(n == vp.len); + break :vp vp; + }; + + const len = fillBody(null, pane, vp); + const out = try arena.alloc(u8, len); + const filled = fillBody(out, pane, vp); + std.debug.assert(filled == out.len); + return out; + } + + pub const BodyRow = union(enum) { + grid: i32, + edit: struct { line: []const u8, idx: usize }, + }; + + const BodyWalk = struct { + pane: *Pane, + goff: i32, + g: i32, + /// the buffer can start above the viewport: drop the lines scrolled past + skip: usize, + n: usize = 0, + lines: ?std.mem.SplitIterator(u8, .scalar) = null, + covered: i32 = 0, + line_idx: usize = 0, + + fn init(pane: *Pane) BodyWalk { + const off = pane.scroll(); + const goff = gridOffset(pane); + return .{ + .pane = pane, + .goff = goff, + .g = if (pane.mode == .tty) goff else pane.gridRow(off), + .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, + }; + } + + fn next(w: *BodyWalk) ?BodyRow { + while (w.n < w.pane.rows) { + if (w.lines) |*it| { + if (it.next()) |line| { + const idx = w.line_idx; + w.line_idx += 1; + // Lines scrolled off the top still count: the index names a + // line of the BUFFER, not of the visible body. + if (w.skip > 0) { + w.skip -= 1; + continue; + } + w.n += 1; + return .{ .edit = .{ .line = line, .idx = idx } }; + } + // The buffer stands in for `rows` shell rows however many lines + // it actually spelled, which is the whole slide. + w.g += w.covered; + w.skip = 0; + w.lines = null; + continue; + } + if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { + w.lines = std.mem.splitScalar(u8, o.text, '\n'); + w.covered = o.rows; + w.line_idx = 0; + continue; + }; + const vi = w.g - w.goff; + w.g += 1; + w.n += 1; + return .{ .grid = vi }; + } + return null; + } + }; + + /// Run the terminal body row walk. A null destination counts bytes; a slice + /// fills the exact allocation made from that count. + fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { + var walk: BodyWalk = .init(pane); + var written: usize = 0; + var first = true; + while (walk.next()) |row| { + if (!first) { + if (dst) |out| out[written] = '\n'; + written += 1; + } + first = false; + const bytes = switch (row) { + .edit => |e| e.line, + .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) + viewport[@intCast(vi)] + else + "", + }; + if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); + written += bytes.len; + } + return written; + } + + // Match surviving edit-buffer rows to their original terminal styles. + const EditAnchors = struct { + /// the buffer's own text, walked in order: a line the VIEWPORT skipped still + /// consumes the row it came from, so the lines below it stay aligned + text: []const u8 = &.{}, + at: usize = 0, + shell: []const []const u8 = &.{}, + /// the covered span, absolute grid rows, as `[first, end)` + first: usize = 0, + end: usize = 0, + lines: usize = 0, + /// the line `at` names, and the first row still unclaimed + idx: usize = 0, + cursor: usize = 0, + budget: usize = 0, + active: bool = false, + + fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { + if (o.rows <= 0 or o.row < 0) return .{}; + const first: usize = @intCast(o.row); + const screen = pane.terminal.?.vt.screens.active; + const total = screen.pages.scrollbar().total; + if (first >= total) return .{}; + const covered: usize = @intCast(o.rows); + const count = @min(covered, total - first); + const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{}; + const br = screen.pages.pin(.{ .screen = .{ + .x = screen.pages.cols - 1, + .y = @intCast(first + count - 1), + } }) orelse return .{}; + const arena = p.scratch.allocator(); + var output: std.Io.Writer.Allocating = .init(arena); + screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{}; + const shell = arena.alloc([]const u8, count) catch return .{}; + var raw = std.mem.splitScalar(u8, output.written(), '\n'); + var pins = tl.rowIterator(.right_down, br); + for (shell) |*row| { + const text = raw.next() orelse ""; + row.* = if (pins.next()) |pin| + if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text + else + text; + } + const lines = std.mem.count(u8, o.text, "\n") + 1; + return .{ + .text = o.text, + .shell = shell, + .first = first, + .end = first + count, + .lines = lines, + .cursor = first, + .budget = covered + 4 * lines, + .active = true, + }; + } + + fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { + if (!a.active or idx >= a.lines) return null; + var found: ?Anchor = null; + while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); + return found; + } + + fn nextLine(a: *EditAnchors) ?[]const u8 { + if (a.at > a.text.len) return null; + const rest = a.text[a.at..]; + if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { + a.at += n + 1; + return rest[0..n]; + } + // The last line has no terminator; one past the end ends the walk. + a.at = a.text.len + 1; + return rest; + } + + /// Where this line still stands over the grid, if anywhere. + fn claim(a: *EditAnchors, line: []const u8) ?Anchor { + const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; + var k = a.cursor; + while (k < end) : (k += 1) { + if (a.budget == 0) return null; + a.budget -= 1; + if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue; + a.cursor = k + 1; + return .{ .row = @intCast(k) }; + } + if (a.cursor >= a.end) return null; + const shell = a.shell[a.cursor - a.first]; + var p: usize = 0; + while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; + var s: usize = 0; + const room = @min(line.len, shell.len) - p; + while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; + if (p + s == 0) return null; + if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; + const row = a.cursor; + if (s > 0 or p >= shell.len) a.cursor += 1; + return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; + } + }; + + const Anchor = struct { + /// the row, absolute while it comes from `EditAnchors`, viewport once + /// `recolorAnsi` has subtracted the walk's offset + row: i32, + prefix: usize = std.math.maxInt(usize), + suffix: usize = 0, + /// the row's own dumped length — what the suffix is measured from on the + /// GRID side, where the edit may have changed the byte count + shell_len: usize = 0, + + fn whole(an: Anchor) bool { + return an.prefix == std.math.maxInt(usize); + } + }; + + pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { + // No emulator, no ANSI cells: the whole pass — and the 256-colour theme + // projection behind it — is compiled out. + if (comptime !enabled) return; + const s = &p.surface; + if (pane.terminal == null) return; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + var filtered_storage: FilteredColors = undefined; + const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { + const tz_filter = tracy.zone(@src(), "filterInit"); + defer tz_filter.end(); + filtered_storage = FilteredColors.init(p, pane); + break :blk &filtered_storage; + } else null; + // DECSCNM, read once: the filtered palette folds it in itself, the raw + // path needs it per cell. + const scnm = pane.terminal.?.vt.modes.get(.reverse_colors); + const pages = &pane.terminal.?.vt.screens.active.pages; + const vp_rows: i32 = @intCast(scrollbar(pane).len); + // Which buffer lines the user has not actually changed, so a row swallowed + // by a growing buffer keeps the colour it still stands over. + var anchors: ?EditAnchors = null; + var walk: BodyWalk = .init(pane); + var lines = std.mem.splitScalar(u8, body, '\n'); + var vr: u16 = 0; + while (walk.next()) |row| : (vr += 1) { + if (vr >= body_h) break; + // Before any early exit below, or the lines fall out of step with rows. + const text = lines.next() orelse ""; + const anchor: Anchor = switch (row) { + .edit => |e| blk: { + if (anchors == null) anchors = .init(p, pane, pane.ovl.?); + var an = anchors.?.shellRow(e.idx) orelse continue; + an.row -= walk.goff; + break :blk an; + }, + .grid => |v| .{ .row = v }, + }; + const vi = anchor.row; + if (vi < 0 or vi >= vp_rows) continue; + const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; + const cut: u16 = if (pane.mode == .tty) 0 else cut: { + if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; + break :cut switch (promptCut(row_pin)) { + .keep => 0, + // Blanked end to end: the row shows nothing of the grid, so + // projecting the prompt's own colours onto it would be a lie. + .blank => continue, + .cut => |n| std.math.cast(u16, n) orelse continue, + }; + }; + if (cut > 0 and text.len == 0) continue; + var at: usize = 0; + var sc: u16 = 0; + var gc: u16 = cut; + var sb: usize = 0; + const mine_from = @min(anchor.prefix, text.len); + const mine_to = text.len - @min(anchor.suffix, text.len); + var crossed = false; + while (at < text.len and sc < tw) { + const stop = modal.nextGrapheme(text, at); + if (stop <= at) break; + const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; + if (at >= mine_from and at < mine_to) { + sc += span; + at = stop; + continue; + } + if (at >= mine_to and !crossed) { + crossed = true; + const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); + while (sb < upto) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + sb += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + } + } + const want = stop - at; + // Consume every cell that contributed to this grapheme. A cluster + // ghostty split across several cells is still ONE printed glyph. + var covered: usize = 0; + var style: ?pardes.CellStyle = null; + while (covered < want) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) + style = cellStyle(p, ci, filtered, scnm); + covered += dumpedBytes(row_pin, ci.cell); + gc = std.math.add(u16, gc, 1) catch break; + // A spacer contributes no bytes; without this the loop would + // spin on a row that ends in one. + if (covered == 0 and gc >= pane.cols) break; + } + while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { + if (t.cell.wide != .spacer_tail) break; + gc = std.math.add(u16, gc, 1) catch break; + } + if (style) |st| for (0..span) |k| { + const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = st; + }; + sb += covered; + sc += span; + at = stop; + } + var tail: ?pardes.CellStyle = null; + if (anchor.whole() or anchor.suffix > 0) { + while (sc < tw) { + const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; + gc = std.math.add(u16, gc, 1) catch break; + if (ci.cell.wide == .spacer_tail) continue; + const cell = s.at(tx + sc, body_y + vr); + sc += 1; + const style = cellStyle(p, ci, filtered, scnm); + tail = style; + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + } + if (tail) |style| while (sc < tw) : (sc += 1) { + const cell = s.at(tx + sc, body_y + vr); + if (cell.default and filtered == null) continue; + cell.default = false; + cell.style = style; + }; + } + } + } + + fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { + const style = ci.style(); + var cs: pardes.CellStyle = .{ + .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), + .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), + .bold = style.flags.bold, + .dim = style.flags.faint, + .italic = style.flags.italic, + .blink = style.flags.blink, + .reverse = style.flags.inverse, + .invisible = style.flags.invisible, + .strikethrough = style.flags.strikethrough, + .ul = switch (style.flags.underline) { + .none => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; + if (filtered == null) switch (ci.cell.content_tag) { + .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), + .bg_color_rgb => { + const rgb = ci.cell.content.color_rgb; + cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + }, + else => {}, + }; + return cs; + } + + const FilteredColors = struct { + source: GColor.Palette, + target: *const GColor.Palette, + theme_bg: GColor.RGB, + theme_fg: GColor.RGB, + dynamic_bg: ?GColor.RGB, + dynamic_fg: ?GColor.RGB, + default_bg: GColor.RGB, + /// What a foreground too near `default_bg` becomes instead. + fallback_fg: GColor.RGB, + default_bg_luminance: f64, + fg_for_palette: [256]pardes.Color = undefined, + bg_for_palette: [256]pardes.Color = undefined, + /// The two answers for a cell that names no colour of its own. + fg_default: pardes.Color = undefined, + bg_default: pardes.Color = undefined, + cache_rgb: [256]GColor.RGB = undefined, + cache_key: [256]u8 = undefined, + cache_valid: [256]bool = @splat(false), + + fn init(p: *Pardes, pane: *const Pane) FilteredColors { + var source = GColor.default; + for (&source, 0..) |*rgb, i| + rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; + const theme = p.theme(); + var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); + var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); + var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }); + var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }); + if (pane.terminal.?.vt.modes.get(.reverse_colors)) { + std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); + std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); + } + var self: FilteredColors = .{ + .source = source, + .target = p.tty_filter_palette.get(theme), + .theme_bg = theme_bg, + .theme_fg = theme_fg, + .dynamic_bg = dynamic_bg, + .dynamic_fg = dynamic_fg, + .default_bg = theme_bg, + .fallback_fg = theme_fg, + .default_bg_luminance = luminanceOf(theme_bg), + }; + if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); + self.default_bg_luminance = luminanceOf(self.default_bg); + if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) + self.fallback_fg = self.theme_bg; + + self.fg_default = asPardesColor(self.legible( + if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, + )); + self.bg_default = asPardesColor(self.default_bg); + for (&self.source, 0..) |current, i| { + const idx: u8 = @intCast(i); + const mapped = self.paletteRgb(idx, current); + self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); + self.bg_for_palette[idx] = asPardesColor(mapped); + } + return self; + } + + fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { + return switch (style.fg_color) { + .none => self.fg_default, + .palette => |idx| self.fg_for_palette[idx], + .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ + .default = self.dynamic_fg orelse self.theme_fg, + .palette = &self.source, + .bold = null, + })))), + }; + } + + fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { + switch (cell.content_tag) { + .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], + .bg_color_rgb => {}, + else => switch (style.bg_color) { + .none => return self.bg_default, + .palette => |idx| return self.bg_for_palette[idx], + .rgb => {}, + }, + } + // Truecolour, from either the cell or its style. + return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); + } + + fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { + if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= + config.tty_filter_min_contrast) return rgb; + return self.fallback_fg; + } + + fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { + if (current.eql(GColor.default[idx])) return self.target[idx]; + return self.keyedRgb(current); + } + + fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { + return self.target[self.nearestKey(rgb)]; + } + + fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { + const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; + const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); + if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) + return self.cache_key[slot]; + + var best: u8 = 0; + var best_distance: u32 = std.math.maxInt(u32); + for (GColor.default, 0..) |candidate, i| { + const distance = colorDistance(rgb, candidate); + // Strict comparison makes duplicate-colour ties stable at the + // lowest canonical xterm key. + if (distance < best_distance) { + best_distance = distance; + best = @intCast(i); + } + } + self.cache_rgb[slot] = rgb; + self.cache_key[slot] = best; + self.cache_valid[slot] = true; + return best; + } + }; + + const channel_luminance: [256]f64 = blk: { + @setEvalBranchQuota(20000); + var table: [256]f64 = undefined; + for (&table, 0..) |*slot, c| { + const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; + slot.* = if (normalized <= 0.03928) + normalized / 12.92 + else + std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); + } + break :blk table; + }; + + fn luminanceOf(rgb: GColor.RGB) f64 { + return 0.2126 * channel_luminance[rgb.r] + + 0.7152 * channel_luminance[rgb.g] + + 0.0722 * channel_luminance[rgb.b]; + } + + /// ghostty's `RGB.contrast` with both luminances already in hand. + fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { + const lighter = @max(a_luminance, b_luminance); + const darker = @min(a_luminance, b_luminance); + return (lighter + 0.05) / (darker + 0.05); + } + + fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { + const dr = @as(i32, a.r) - @as(i32, b.r); + const dg = @as(i32, a.g) - @as(i32, b.g); + const db = @as(i32, a.b) - @as(i32, b.b); + return @intCast(dr * dr + dg * dg + db * db); + } + + test "the luminance table answers exactly what ghostty computes" { + for (0..256) |i| { + const c: u8 = @intCast(i); + const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; + try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); + } + // Channel weights are asymmetric, so a grey ramp alone would not catch a + // transposed coefficient. The palette is what the tables enumerate. + for (GColor.default) |candidate| { + try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); + for (GColor.default) |page| { + try std.testing.expectEqual( + candidate.contrast(page), + contrastOf(luminanceOf(candidate), luminanceOf(page)), + ); + } + } + } + + test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ + "\x1b[38;5;196mB" ++ + "\x1b[38;2;255;0;0mC" ++ + "\x1b[0;48;5;25mD" ++ + "\x1b[0;48;2;0;95;175mE" ++ + "\x1b[0;1;2;3;4;5;7;8;9mF" ++ + "\x1b[0;48;5;25m\x1b[K" ++ + "\r\n\x1b[0;38;5;2m界" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const raw = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); + + pane.tty_filter = true; + _ = frame.reset(.retain_capacity); + const filtered = try p.render(frame.allocator()); + var expected_cache: FilterPalette = .{}; + const expected = expected_cache.get(p.theme()); + try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); + try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); + try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); + + const attrs = filtered.at(tx + 5, body_y).style; + try testing.expect(attrs.bold); + try testing.expect(attrs.dim); + try testing.expect(attrs.italic); + try testing.expect(attrs.blink); + try testing.expect(attrs.reverse); + try testing.expect(attrs.invisible); + try testing.expect(attrs.strikethrough); + try testing.expectEqual(.single, attrs.ul); + + const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; + try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); + try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); + try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); + try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); + // A filtered terminal never delegates either colour to a backend palette, + // including cells which were empty/default before the pass. + for (0..r.w - config.GUTTER) |col| { + const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); + try testing.expect(!cell.default); + switch (cell.style.fg) { + .rgb => {}, + else => return error.FilteredForegroundWasNotRgb, + } + switch (cell.style.bg) { + .rgb => {}, + else => return error.FilteredBackgroundWasNotRgb, + } + } + + // Colors remains the global master gate. The pane remembers Filter while + // ANSI projection is dormant, and resumes it without replaying VT bytes. + p.settings.colors = false; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + try testing.expect(pane.tty_filter); + try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); + p.settings.colors = true; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); + const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; + try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); + pane.tty_filter = false; + pane.tty_filter = true; + try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); + _ = frame.reset(.retain_capacity); + const osc_palette = try p.render(frame.allocator()); + try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); + + // Dynamic default foreground/background colours key every default cell, + // including the otherwise blank end of the row. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ + "\x1b]11;#5f5f5f\x1b\\" } }); + const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?; + const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?; + try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); + try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); + _ = frame.reset(.retain_capacity); + const dynamic = try p.render(frame.allocator()); + const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), blank.bg); + + const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; + try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); + const reversed_explicit = reversed.at(tx, body_y).style; + try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); + try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); + try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); + _ = frame.reset(.retain_capacity); + const unreversed = try p.render(frame.allocator()); + const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; + try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); + try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); + + // The cache is keyed by values, not a theme name. Replacing a custom + // theme in place immediately recolours already-rendered indexed cells. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); + var custom = p.theme().*; + custom.name = try p.gpa.dupe(u8, "same-name"); + custom.palette = null; + custom.kw = .{ 1, 2, 3 }; + p.custom_theme = custom; + _ = frame.reset(.retain_capacity); + const custom_first = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); + if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; + _ = frame.reset(.retain_capacity); + const custom_second = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); + } + + test "terminal Filter keeps extended keys dark-to-light on a light theme" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + // Curated order is a public theme contract: helix, dark, acme. + p.settings.theme = 2; + try std.testing.expectEqualStrings("acme", p.theme().name); + var cache: FilterPalette = .{}; + const palette = cache.get(p.theme()); + try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); + try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); + } + + test "terminal Filter preserves exact palette-null light theme default roles" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + var light = p.theme().*; + light.name = try p.gpa.dupe(u8, "filter-light-defaults"); + light.bg = .{ 0xf8, 0xf8, 0xf8 }; + light.fg = .{ 0x38, 0x38, 0x38 }; + light.palette = null; + p.custom_theme = light; + + const pane = p.panes[0].?; + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground })); + try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background })); + pane.tty_filter = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const ordinary = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + _ = frame.reset(.retain_capacity); + const reversed = try p.render(frame.allocator()); + try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); + } + + test "terminal Filter maps the default roles before it maps anything else" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + for (0..3) |t| { + p.settings.theme = @intCast(t); + const stage_one = FilteredColors.init(p, pane); + // `dark` declares no background of its own, which is exactly why the + // resolver reads the tag colours as the fallback rather than `.?`. + const theme = p.theme(); + try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); + try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); + // With no OSC 11 in play the mapped page IS that anchor, and the + // fallback is the other one: a background never contrasts with itself. + try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); + try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); + } + + p.settings.theme = 0; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); + var moved = FilteredColors.init(p, pane); + try testing.expect(!moved.default_bg.eql(moved.theme_bg)); + try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); + } + + test "terminal Filter refuses a foreground that would collapse onto the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + try testing.expect(pane.tty_filter); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ + "\x1b[0;30mB" ++ + "\x1b[0;31mR" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + const page = asPardesColor(fc.default_bg); + const rescued = asPardesColor(fc.fallback_fg); + _ = frame.reset(.retain_capacity); + const g = try p.render(frame.allocator()); + + // The colour each of the three would have been given with no floor. + const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); + const raw_black = fc.paletteRgb(0, GColor.default[0]); + const raw_red = fc.paletteRgb(1, GColor.default[1]); + + for ([_]struct { at: u16, raw: GColor.RGB }{ + .{ .at = 0, .raw = raw_white }, + .{ .at = 1, .raw = raw_black }, + .{ .at = 2, .raw = raw_red }, + }) |case| { + const cell = g.at(tx + case.at, body_y).style; + try testing.expectEqual(page, cell.bg); + if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { + // Refused: the projection's answer is not painted, the anchor is. + try testing.expectEqual(rescued, cell.fg); + try testing.expect(!std.meta.eql(cell.fg, cell.bg)); + } else { + // Cleared the floor, so stage two leaves it exactly alone. + try testing.expectEqual(asPardesColor(case.raw), cell.fg); + } + // Either way a filtered cell delegates neither colour to a backend. + switch (cell.fg) { + .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= + config.tty_filter_min_contrast), + else => return error.FilteredForegroundWasNotRgb, + } + } + + // At least one of the three has to have been a real collapse, or this + // theme proved nothing: white on the light theme, black on the dark. + try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or + raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); + // A saturated red is never the page on any curated theme. + try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + } + } + + test "terminal Filter holds every projected foreground off the page" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + + for (0..3) |t| { + p.settings.theme = @intCast(t); + var fc = FilteredColors.init(p, pane); + var refused: usize = 0; + for (fc.target, 0..) |projected, key| { + const ink = fc.legible(projected); + try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); + if (!ink.eql(projected)) { + refused += 1; + try testing.expect(ink.eql(fc.fallback_fg)); + // Only ever refused for being too near the page. + try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); + } + // The key a background asks for is handed back untouched, including + // the one whose value is the page itself. + try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); + } + // Every curated theme owns at least one collapsing key — that is why + // the floor exists — and the floor must not be flattening the palette. + try testing.expect(refused > 0); + try testing.expect(refused < fc.target.len / 8); + } + } + + test "tty ansi colors follow the prompt hug into normal mode" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const blue: pardes.Color = .{ .index = 4 }; + + // tty mode projects the emulator's ansi colours cell for cell. + pane.mode = .tty; + p.shell_rows.stale = true; + const tty = try p.render(frame.allocator()); + try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); + try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); + + pane.mode = .normal; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const norm = try p.render(frame.allocator()); + try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); + try testing.expectEqual(red, norm.at(tx, body_y).style.fg); + try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); + } + + test "a prompt row hidden end to end paints nothing at all" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); + try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg)); + } + + pub fn palColor(p: *Pardes, idx: u8) pardes.Color { + if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; + return .{ .index = idx }; + } + + pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { + return switch (color) { + .none => blk: { + const t = if (is_bg) p.theme().bg else p.theme().fg; + break :blk if (t) |c| .{ .rgb = c } else .default; + }, + .palette => |idx| palColor(p, idx), + .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, + }; + } + + /// executing at a prompt with typed text below it: pad the output area + /// with newlines so the command's output doesn't overwrite the buffer + pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { + // Nothing to pad away from: with no emulator there is no prompt and no + // child whose output could land on top of the edit buffer. + if (comptime !enabled) return; + const pane = p.panes[id] orelse return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; + const state = pane.terminal orelse return; + if (!state.vt.cursorIsAtPrompt()) return; + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y))); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) p.emitWrite(id, "\r"); + } + + /// the snapshot takes ownership of a COPY of the edit buffer's text + pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { + var ovl: ?EditBuffer = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; + } + + /// undo/redo restores the selection recorded with the snapshot (helix + /// keeps selections in its history transactions) + pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = s.ovl; + pane.cur_row = s.cur_row; + pane.cur_col = s.cur_col; + pane.cur_pinned = true; + pane.vsel = s.vsel; + pane.msel.active = false; + pane.ensureCursorVisible(); + } + + fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { + if (len.* == slots.len) { + if (slots[0].ovl) |overlay| gpa.free(overlay.text); + std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = value; + len.* += 1; + } + + pub fn pushUndo(p: *Pardes, pane: *Pane) void { + const current = pane.ovl orelse EditBuffer{ .rows = 0 }; + if (pane.ed_undo_len > 0) { + const top = pane.ed_undo[pane.ed_undo_len - 1]; + const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and + overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; + if (same) return; + } + const value = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); + for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); + pane.ed_redo_len = 0; + } + + pub fn undo(p: *Pardes, pane: *Pane) void { + if (pane.ed_undo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); + pane.ed_undo_len -= 1; + restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); + } + + pub fn redo(p: *Pardes, pane: *Pane) void { + if (pane.ed_redo_len == 0) return; + const current = snap(p, pane) orelse return; + pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); + pane.ed_redo_len -= 1; + restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); + } + + pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { + const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal)); + const room = state.reply.len - state.reply_len; + const n = @min(room, data.len); + @memcpy(state.reply[state.reply_len..][0..n], data[0..n]); + state.reply_len += @intCast(n); + } + + const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( + @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), + ).optional.child).pointer.child).@"fn".return_type.?; + pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { + return .{}; + } +}; + +test { + _ = Pane; + _ = File; + _ = Output; + _ = Mini; + _ = Image; + _ = Pdf; + _ = Terminal; +} diff --git a/src/pardes.zig b/src/pardes.zig index 6edf3600..8d5f0ba7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1,165 +1,49 @@ -//! The pardes core: a text environment as a library, the way ghostty-vt is a -//! library. The platform shell owns the event loop and process-facing IO; it -//! feeds this core events (input, pty bytes, resizes) and reads back two plain values: -//! a Surface — the canonical cell-grid interface, which the tty shell hands to -//! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects, -//! the IO the core wants performed (spawn a shell, write a pty, open a link). -//! Path-backed document and search operations may read synchronously; work -//! which needs a host or event loop is emitted as an Effect. -//! -//! Platform divergence inside the core is the `platform` comptime tag, used -//! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig. -//! -//! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one -//! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what -//! says it is core, and nothing needs a header to claim it. -//! -//! Not one key, button or piece of Look syntax is spelled in this file: every -//! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are -//! the only two matchers. That is so retargeting anything is an edit in one -//! file, and so a later builtin can enumerate the bindings the way Help already -//! enumerates the leader. const std = @import("std"); -pub const animation = @import("animation.zig"); -pub const panel_animation = @import("panel_animation.zig"); +pub const layout = @import("layout.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const mvzr = @import("mvzr"); -const modal = @import("modal.zig"); -const normal_input = @import("normal_input.zig"); -const look = @import("look.zig"); +pub const modal = @import("modal.zig"); +pub const look = @import("look.zig"); +pub const filesystem = @import("fs.zig"); pub const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); -const term_pane = @import("term_pane.zig"); -const file_pane = @import("file_pane.zig"); -const image_pane = @import("image_pane.zig"); -pub const pdf_pane = @import("pdf_pane.zig"); -const output_pane = @import("output_pane.zig"); -const builtins = @import("builtins.zig"); -const runtime_cfg = @import("runtime_config.zig"); -/// Every board-shaped capacity, in one table keyed on a profile rather than on -/// the platform. See src/limits.zig. -const limits = @import("limits.zig"); -const message = @import("message.zig"); +pub const panes = @import("panes.zig"); +pub const builtins = @import("builtins.zig"); +const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); -/// acme's control filesystem, as a pure transaction over this core: the FILES -/// a script opens (`body`, `ctl`, `event`, ...) and what they mean. The -/// transport that carries requests in is a host's business (src/fuse.zig). -pub const acmefs = @import("acmefs.zig"); pub const config = @import("config.zig"); -pub const pdf_enabled = pdf_pane.enabled; -pub const pdf = pdf_pane.pdf; -pub const allocators = @import("allocators.zig"); +pub const pdf_enabled = panes.Pdf.enabled; +pub const pdf = panes.Pdf.pdf; +pub const memory = @import("memory.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); -/// The host seam: one struct of optional function pointers, with in-core -/// defaults for every method a host leaves null. See src/host.zig. -const host_mod = @import("host.zig"); -pub const Host = host_mod.Host; -pub const Fanout = host_mod.Fanout; -pub const Fallback = host_mod.Fallback; -pub const fallback_dump_path = host_mod.fallback_dump_path; -/// Tracy's frame boundary, re-exported so a host that is not a shell — the -/// fling benchmark — can delimit the same frames the tty loop delimits without -/// reaching around the core for src/tracy.zig and its build options. A no-op -/// unless -Dtracy names a Tracy checkout. +const host_io = @import("host_io.zig"); +pub const Host = host_io.Host; pub const frameMark = tracy.frameMark; -/// `p4` is ESP32-P4 firmware: a riscv32-freestanding core whose whole host is -/// a serial line. It joins `web` in having no filesystem, no ptys and no -/// config directory, which is what `hosted` below is for. pub const Platform = enum { tty, gui, web, macos, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); -/// Whether a theme change FADES the anchored chrome palette or replaces it. Ten display frames -/// either way (`animation.transition_steps`), and on every screen but one that is a short legible -/// transition rather than a glitch. -/// -/// The exception is a screen reached through a UART. Each of the ten steps recolors every anchored -/// cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times -/// over, for a fade nobody can see arrive gradually anyway. Off by default for `esp32p4` and settable -/// either way from the build, because the thing that makes it wrong is the transport rather than the -/// target - see `build.zig`. pub const theme_animation = @import("pardes_config").theme_animation; -/// WHICH BUILD THIS IS, for `--version` and for any bug report that follows it. -/// -/// `version` is `build.zig.zon`'s `.version`, read from the manifest by -/// `build.zig` rather than copied beside it, so there is exactly one place to -/// bump. `commit` is the git revision it was built from, and it is OPTIONAL -/// because a source drop is not a repository: a tarball, a container with no -/// `git`, or any checkout outside version control all yield null, and a -/// frontend must say the version happily without one. -/// -/// Both are strings the build baked in, never questions 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 — and on the board there is -/// neither a `git` nor a process to run it with. pub const version = @import("pardes_config").version; pub const commit: ?[]const u8 = @import("pardes_config").commit; -/// A build with no host but its display: the embedded source filesystem, the -/// in-process clipboard, silent ptys. Comptime, and its own option module -/// rather than a `pardes_config` field, because it is the one setting that -/// produces a SECOND executable from the same graph — see `run-isolated`. pub const isolated = @import("pardes_isolation").isolated; -/// Frontends that draw their own text, and can therefore be told which face to -/// wear. On the tty the font belongs to the terminal emulator and in the -/// browser it belongs to the page, so there the Font builtins are not -/// disabled so much as meaningless — see builtins.zig. pub const font_picker = platform == .gui or platform == .macos; -/// Platforms whose host is a real operating system: a filesystem to open, a -/// pty to fork, a config directory to watch. The browser and the P4 firmware -/// have none of the three, and every gate that used to read `platform != .web` -/// reads this instead so a third such platform cannot forget one of them. pub const hosted = platform == .tty or platform == .gui or platform == .macos; -/// Builds whose frontend can hand its screen to a detached core — which is -/// narrower than `hosted`, and the gap is a bug this predicate exists to close. -/// -/// macOS is hosted, has a unix socket, and compiles `detached/`; what it does -/// not do is POLL. `takeAttach` is a poll rather than a host method precisely -/// because attaching replaces the core the call is running inside (see -/// `Effect.attach`), and `src/macos.zig` never calls it. Gated on `hosted`, the -/// `Attach` word therefore parsed, queued an effect, stored a request in -/// `attach_buf` — and did nothing at all, for ever, silently. That is the -/// failure this codebase refuses everywhere else, so the word does not exist -/// on a frontend that cannot serve it. -/// -/// The two here are exactly the two `main.zig` accepts `--attach` for, which is -/// the same question asked at the command line instead of in a tag. pub const can_attach = platform == .tty or platform == .gui; -/// Builds that HAVE terminal panes: a pane whose content is a live ghostty-vt -/// emulator being fed pty bytes. The P4 firmware has no processes, no ptys and -/// nothing that could produce a VT byte, so there the emulator is ~400 KiB of -/// flash and a PageList of RAM spent parsing input that cannot arrive — and it -/// drags a pile of freestanding root hooks in behind it (os.PATH_MAX, -/// os.heap.page_allocator, a cwd handle), none of which the core itself wants. -/// False means ghostty-vt is not in the module graph at all: build.zig never -/// even asks for the dependency. -/// -/// A PLATFORM gate and deliberately NOT one derived from the target: `web` is -/// freestanding too and KEEPS the emulator, because the browser shell renders -/// terminal panes back out of a replayed dump. Every gate in the core keys off -/// this one name, and src/term_pane.zig re-exports it as `enabled` and owns -/// the whole seam — the two Pane slots included — so ghostty-vt ends up -/// imported by exactly one file. pub const terminal_panes = platform != .esp32p4; -/// ...and the one fact about that face the core keeps: the name `Font` last -/// resolved, which the Debug overlay prints. Behind the same comptime shim -/// builtins.zig and macos.zig import this file with, so a tty or web binary -/// never analyses a font-directory walk it cannot use. -const fonts = if (font_picker) @import("fonts.zig") else struct {}; +pub const fonts = if (font_picker) @import("fonts.zig") else struct {}; -/// Native PDF quality is a shell property, but the core owns MuPDF and the -/// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text -/// quality and asks the renderer to cover either fit axis without upscaling. -pub const PdfRasterPolicy = pdf_pane.RasterPolicy; +pub const PdfRasterPolicy = panes.Pdf.RasterPolicy; pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 96, @@ -181,37 +65,17 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .web, .esp32p4 => kitty_pdf_raster_policy, }; -// The capacities and the two heights that are STRUCTURE, not taste: the -// fixed-size pane/column arrays, and the fact that the topbar and a tag are -// one row each (nothing here works at any other value). The layout numbers -// that ARE taste — the gutter, the line-number prefix, scrolloff, the pane -// minimums — live in config.zig with everything else a user retargets. +pub const Pane = panes.Pane; + pub const MAX_PANES = 16; -pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px; +pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; -/// 32 fractional bits leave ample precision for resize/restored ratios while -/// allowing every possible column split to divide an initial weight exactly. -const column_weight_unit: u64 = 1 << 32; -const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; -/// how far back the jump stack remembers. Vim keeps 100; this is a session of -/// at most sixteen panes, so the depth that matters is "more visits than you -/// can hold in your head" and the oldest entry falls off the bottom. +const column_weight_unit = layout.column_weight_unit; +const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; -/// Where a reduced-height tagline band sits inside its body-sized grid row, in -/// physical pixels down from the row's top. ONE rule for both pixel hosts: the -/// SDL shell (`src/gui/gui.zig`) and the AppKit shell (`src/macos.zig`, over the -/// C ABI) both call this. It lived in gui.zig, the native shell grew its own -/// copy that only ever centred, and centring is precisely the case -/// `config.gui_topbar_pane_border_px` exists to avoid: two half-bands touching -/// with a strip of window background showing between them, widening as the -/// tagline face shrinks. -/// -/// Row zero and the first pane-tag row face a shared rule instead of centering -/// two independent bands. A Tagbottom band on the final grid row faces the -/// window edge, eliminating the matching unused half-band at the bottom. pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; const border = topbarPaneBorderPixels(cell_h, tagline_h); @@ -222,37 +86,12 @@ pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u return spare / 2; } -/// The rule between the topbar band and the first pane-tag band, clamped to the -/// spare pixels those two bands have between them so a wide compiled value -/// cannot paint over either. pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } -/// The column a compact tagline band anchors at: the left edge of the pane -/// whose tag row this cell sits on. ONE rule for both pixel hosts, for exactly -/// the reason `taglineBandOffset` is one — the SDL shell reached this through -/// its own copy of the pane walk, and the AppKit shell could not do the walk at -/// all (pane rects are not on its C ABI), so its tag rows advanced on BODY -/// pitch with the smaller glyph merely centred in each body cell. Same session, -/// same percentage, visibly looser tracking in one of the two windows. -/// -/// CELLS, and fractional on purpose: an animating panel's box is fractional, -/// and rounding here would step a sliding pane's tag row a whole body cell at a -/// time while the rest of the pane moved smoothly. -/// -/// `track` is the panel track painting this cell, when one is. It is a -/// parameter rather than something looked up here because the caller has -/// already decided which track owns the cell — the SDL shell from its paint -/// plan, the C ABI wrapper from the frame's track list — and two answers to -/// that question is the drift this function exists to prevent. -/// -/// The last resort is the cell's own column, which puts that one cell back on -/// body pitch. That is deliberate: a stale cell whose pane has closed, or any -/// cell of an attached window, still has to be legible, and a band anchored at -/// a pane that no longer exists is not. -pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_animation.Track) f32 { +pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; if (track) |active| { const box = active.contentBox(); @@ -270,12 +109,6 @@ pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?panel_anim return @floatFromInt(col); } -/// `taglineOriginCol` for a host with no paint plan of its own: the owning -/// track is resolved from the frame's own list. The SDL shell already knows -/// which track is painting a cell and passes it; AppKit reaches the grid -/// through the C ABI and does not, so the lookup belongs here rather than in -/// the wrapper — a second answer to "which track owns this cell" is exactly -/// the drift `taglineOriginCol` was moved into the core to stop. pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { for (p.surface.panelTracks()) |track| if (track.contentBox().contains(col, row)) @@ -284,7 +117,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { } test "paint order is moving, then opening, then closing tombstones on top" { - const Track = panel_animation.Track; + const Track = layout.Track; // Deliberately interleaved on the way in: the phases are what order the // output, not the slot they happened to occupy. const live = [_]?Track{ @@ -298,7 +131,7 @@ test "paint order is moving, then opening, then closing tombstones on top" { .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; - const len = panel_animation.paintOrder(&live, &closing, &out); + const len = layout.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; @@ -307,25 +140,11 @@ test "paint order is moving, then opening, then closing tombstones on top" { // A host's array is fixed-size and the core's is not its business: writing // past it would be a buffer overrun in whichever shell had the smaller one. var tight: [2]Track = undefined; - try std.testing.expectEqual(@as(usize, 2), panel_animation.paintOrder(&live, &closing, &tight)); + try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight)); try std.testing.expectEqual(@as(u32, 12), tight[0].serial); try std.testing.expectEqual(@as(u32, 15), tight[1].serial); } -/// The INVERSE of the two rules above: which grid column a pointer sits in, -/// given where the glyphs actually went. Compacting a tag row without -/// compacting the hit test is a click that lands one word to the right by the -/// end of the row, so these two are one feature and belong in one place. -/// -/// `x` and both widths are in whatever unit the host measures in — physical -/// pixels for SDL, points for AppKit — because only their RATIO is used. -/// -/// The topbar anchors at column zero, a pane tag row at its pane's left edge -/// and is clamped to that pane's last column so a click in the slack at the -/// right of a compacted band stays on the pane it was aimed at, and everything -/// else is the body grid. Deliberately track-blind: the pointer is aimed at -/// what is on screen NOW, and a mid-animation pane is somewhere its own -/// geometry says it is not yet. pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const body = @max(body_w, 1); const tag = @max(tagline_w, 1); @@ -356,7 +175,7 @@ test "a compact tagline anchors at its own pane, and both shells step from the s const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); // Two panes side by side put two tags on ONE row, which is the case a @@ -371,19 +190,9 @@ test "a compact tagline anchors at its own pane, and both shells step from the s // is why row zero is right even in a window with no core to ask. try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); - // A row no pane tags falls back to the cell's own column, which is the - // identity that puts that cell back on body pitch rather than sliding it - // somewhere a closed pane used to be. const body_row = left.y + 2; try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); - // THE CROSS-SHELL CONTRACT. gui.zig lays a compact cell out as - // `x_off + col * tag_w` with `x_off = origin * (body_w - tag_w)`; the - // AppKit shell spells the same placement as - // `origin * body_w + (col - origin) * tag_w`. They are the same line of - // algebra and this is the assertion that keeps them one: the two windows - // are supposed to be indistinguishable at the same percentage, and the - // whole bug was one of them quietly using body pitch. const body_w: f32 = 10; const tag_w: f32 = 8; for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { @@ -393,12 +202,6 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(sdl, appkit); } - // ...and the POINTER agrees with both. Placing a glyph on a narrower pitch - // while still dividing clicks by the body cell is a hit that drifts one - // column further right for every column along the row — dead centre of the - // last word in a wide tag lands on empty space past its end. Forward and - // inverse live in different files and different languages; this is what - // keeps them inverses. for ([_]u16{ 0, 1, 4, 9 }) |offset| { const col = left.x + offset; if (offset >= left.w) break; @@ -417,42 +220,19 @@ test "a compact tagline anchors at its own pane, and both shells step from the s try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } -/// A place the keyboard has been: a pane AND a spot in it, which is the whole -/// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now -/// rewind WITHIN a pane, and a Jumplist row can name a line. -/// -/// It is SAFE against the pane it names dying: `serial` is the pane's own -/// identity, so an entry whose slot has since been handed to a different pane -/// reads as dead rather than silently retargeting itself at the newcomer, and -/// sync() drops it. What it does not do is outlive the pane — ponytail: a -/// location is a place in the SESSION, not on disk, so closing a file forgets -/// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this -/// grows a path field and jumpBy looks it when the pane is gone. pub const Loc = struct { pane: u16, serial: u32, - /// 1-based, both — this is the `path:LINE:COL` a look word spells, and - /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see - /// trackJump: a shell whose cursor is still the program's). line: u32, col: u32, }; -test { - _ = @import("pdf_pane_integration_test.zig"); - _ = @import("output_pane_integration_test.zig"); -} +test {} const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; -// Kept separate from the path so a click still expands to the exact filename. -// It belongs to the live, read-only prefix rather than the editable command -// tail: saving removes it without rewriting anything the user typed there. const dirty_marker = " *"; -// Version-1 dumps originally persisted only the whole rendered tag. These were -// the two canonical tails before New joined every pane; the compatibility -// parser recognizes them as defaults while new dumps carry an explicit tail. const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized @@ -464,24 +244,9 @@ const legacy_terminal_pane_tail = " New Del Filter"; // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; -// Builtins: executing the name (middle-click / Tab) runs it through the ONE -// dispatcher (runBuiltin, reached from execute), no matter where the name -// appears — and Look and Exec are two of them, so the click itself is a -// builtin. One STRUCT per builtin in builtins.zig — name, comment and -// body in one place — and this enum is folded out of THAT FILE'S declarations -// at comptime, so the enum FIELD NAME is still the user-visible word (the one -// in the topbar, the one sitting in a tag, the one Help prints, the one you -// execute) and `std.meta.stringToEnum` is still the lookup with no name table -// to keep in sync. It lands here rather than in builtins.zig because a -// container cannot hold a decl folded out of its own decl list, and here it -// sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.registry.Builtin(); -/// The PDF integration suite lives beside the PDF implementation instead of -/// making the core's first sixteen hundred lines pane-specific. These direct -/// test-only calls reach the few intentionally private core transactions that -/// the suite must observe; the declaration is empty in every non-test build. -pub const pdf_test = if (@import("builtin").is_test) struct { +pub const test_api = if (@import("builtin").is_test) struct { pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return p.tagText(arena, pane); } @@ -519,9 +284,6 @@ fn nextPipeEffect(p: *Pardes) ?u32 { return null; } -/// Perform every queued effect through the in-process host — what a real shell -/// does with the drain — and report the path the last `.save_text` among them -/// asked for, copied out of the effect into `buf`. fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { var len: ?usize = null; while (p.nextEffect()) |effect| { @@ -538,19 +300,12 @@ fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { return if (len) |n| buf[0..n] else null; } -/// Drain the queue through the in-process host — what a shell's pump does with -/// it — and report the Attach among those effects. Going through `perform` is -/// the point: what a frontend acts on is what `takeAttach` hands back AFTER the -/// drain, not the effect value, which dies in the loop that read it. fn drainForAttach(p: *Pardes) ?AttachRequest { while (p.nextEffect()) |effect| p.perform(effect); return p.takeAttach(); } test "Attach asks for a session and tears nothing down" { - // `can_attach` and not `hosted`: 29ac9be compiled both words out of a - // frontend that never polls `takeAttach`, which is macOS — hosted, with a - // unix socket, and still no word to run here. if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); @@ -583,9 +338,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le defer p.deinit(); while (p.nextEffect()) |_| {} - // Whole-word only, like Kill: the effect names the pane that ran it and - // carries nothing else, because the daemon that serves it already knows - // which frontend's keystroke arrived. try std.testing.expect(p.executeBuiltinLine(0, "Detach")); const asked = while (p.nextEffect()) |effect| switch (effect) { .detach => |d| break d, @@ -593,9 +345,6 @@ test "Detach asks the frontend to leave, and says so when there is nothing to le } else return error.NoDetachAsked; try std.testing.expectEqual(@as(u8, 0), asked.pane); - // ...and this core is a LOCAL shell — a bare `Host{}` fills in no - // `push_detach` — so performing it reports on that pane instead of - // dismissing a session this process is not part of. p.perform(.{ .detach = asked }); const pane = p.panes[0].?; try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); @@ -607,18 +356,18 @@ test "selection pipe prompt submits exact request and Escape cancels" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("one\ntwo\n"); + const pane = try p.setTestFile("one\ntwo\n"); pane.cur_row = 0; pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.tag_edit and pane.hasPipePrompt()); + try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); @@ -630,7 +379,7 @@ test "selection pipe prompt submits exact request and Escape cancels" { p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } @@ -641,9 +390,9 @@ test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" defer p.deinit(); while (p.nextEffect()) |_| {} const long = "a" ** 400; - const pane = try p.hxOpenFileContent(long ++ "\nsecond\n"); + const pane = try p.setTestFile(long ++ "\nsecond\n"); p.settings.wrap = true; - const width = file_pane.wrapWidth(pane, true); + const width = panes.File.wrapWidth(pane, true); try std.testing.expect(width > 4 and long.len > width * 3); // gj holds the column INSIDE the row and lands on the next break; the line @@ -710,9 +459,6 @@ test "the message log keeps what the row forgets, and collapses repeats" { p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock p.setMessage(0, "save: AccessDenied"); - // Two entries, not three: the clock does not make a message new. This is - // the case the de-duplication exists for and the one it used to miss, - // because `message.stamp` makes every host message unique by construction. try std.testing.expectEqual(@as(usize, 2), p.messages_len); const first = p.messageLog(0).?; try std.testing.expectEqual(@as(u16, 2), first.repeats); @@ -752,13 +498,12 @@ test "the acme chords act once per selection, not once on the primary" { while (p.nextEffect()) |_| {} // Two selections, each naming a DIFFERENT builtin, so what ran is visible // in the layout rather than in a shell nobody can read from a test. - const pane = try p.hxOpenFileContent("Newcol\nNewcol\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("Newcol\nNewcol\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 6 }, .{ .anchor = 7, .head = 13 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.nsel); const before = p.ncol; @@ -775,13 +520,12 @@ test "selection pipe replaces all ranges atomically and undo restores them" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); @@ -792,15 +536,17 @@ test "selection pipe replaces all ranges atomically and undo restores them" { try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); - // `AA\n` for a selection that was just `aa`: helix takes a trailing newline - // back off when the input did not have one, which is what keeps a one-line - // `| tr a-z A-Z` from becoming two lines. The empty output for `cc` deletes - // it outright, which is a filter's ordinary right. + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); @@ -829,9 +575,8 @@ test "the four shell behaviours put their output where helix puts it" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); + const pane = try p.setTestFile("aa bb cc\n"); + pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); p.update(.{ .key = case.key }); for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -847,13 +592,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("aa bb cc\n"); - const pl = try p.paneCursorLines(pane); - const ranges = [_]modal.HxRange{ + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; - Pardes.setPaneRanges(pane, pl, pane.file.?.content, &ranges, &.{}, 0, true); + pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); @@ -861,15 +605,12 @@ test "a command with no stdin runs once and every cursor gets that one answer" { const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; - // ONE invocation for two cursors, with nothing on its stdin — helix's - // `shell_output` cache. Two invocations of `date` could disagree, and ten - // cursors would mean ten forks to produce one answer. try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe failure and stale completion never mutate the file" { @@ -877,7 +618,7 @@ test "selection pipe failure and stale completion never mutate the file" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -887,16 +628,16 @@ test "selection pipe failure and stale completion never mutate the file" { const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } }); try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } }); try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); } test "a failed filter opens an errors buffer carrying the command's own words" { @@ -904,7 +645,7 @@ test "a failed filter opens an errors buffer carrying the command's own words" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("abc\n"); + const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -925,13 +666,10 @@ test "a failed filter opens an errors buffer carrying the command's own words" { // The text is untouched — a failed filter is not an edit... try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); // ...and the cursor did not go anywhere, so `|` again edits the same file. try std.testing.expectEqual(@as(usize, 0), p.active); - // ...but the reason is now READABLE, in an +Errors buffer: the command as - // typed, what became of it, and what the shell said. Every one of those - // three used to be dropped on the floor. var found: ?[]const u8 = null; for (p.panes) |slot| { const q = slot orelse continue; @@ -950,7 +688,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - var pane = try p.hxOpenFileContent("old\n"); + var pane = try p.setTestFile("old\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -958,10 +696,10 @@ test "selection pipe rejects a reused pane slot and a superseded request" { p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; - pane = try p.hxOpenFileContent("new\n"); // same slot, different serial + pane = try p.setTestFile("new\n"); // same slot, different serial p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -979,7 +717,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } }); try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe binding is file-normal-only" { @@ -987,15 +725,15 @@ test "selection pipe binding is file-normal-only" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("x"); + const pane = try p.setTestFile("x"); pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); - try std.testing.expect(!pane.hasPipePrompt()); + try std.testing.expect(pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } @@ -1004,7 +742,7 @@ test "insert newline adds one indent level after a closing call" { const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent(" callback({})\n"); + const pane = try p.setTestFile(" callback({})\n"); pane.mode = .insert; pane.cur_col = 16; @@ -1027,9 +765,6 @@ test "startup config runs builtin lines in order and isolates bad lines" { }); defer p.deinit(); - // The last valid command wins even after unknown, malformed, and - // well-formed-but-failing lines. `Kill trailing-garbage` must not be - // accepted as Kill, nor fall through to the shell from config. try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); @@ -1133,7 +868,7 @@ test "runtime theme changes animate chrome and retarget without a jump" { try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); - for (0..animation.transition_steps) |_| p.update(.tick); + for (0..layout.Animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. @@ -1224,10 +959,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; - // A real middle-click on the canonical pane-tag word reaches Exec, which - // must consume New as a builtin before any write can reach the shell. - // The builtins are right-aligned (see tagGap), so the column is found in - // the rendered tag rather than assumed to be at its left edge. const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; var panes_before: usize = 0; @@ -1297,12 +1028,12 @@ test "Filter is ordered after Del and toggles only its terminal pane" { defer p.deinit(); while (p.nextEffect()) |_| {} const first = p.panes[0].?; - const palette_before = first.vt.colorForXterm(.{ .palette = 1 }).?; + const palette_before = first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; try testing.expect(first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(!first.tty_filter); - try testing.expect(palette_before.eql(first.vt.colorForXterm(.{ .palette = 1 }).?)); + try testing.expect(palette_before.eql(first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); const second_id = p.freeSlot().?; const second = try p.newShell(second_id, ""); @@ -1312,7 +1043,7 @@ test "Filter is ordered after Del and toggles only its terminal pane" { try testing.expect(!first.tty_filter); const doc_id = p.freeSlot().?; - const doc = try image_pane.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); + const doc = try panes.Image.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); try testing.expect(!doc.tty_filter); try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); try testing.expect(!doc.tty_filter); @@ -1333,9 +1064,9 @@ test "an untouched tagline ends where its layout column's widest one does" { // directory too long to leave tag_right_pad columns free const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); - const f = p.layoutFindTerm(p.active).?; - p.layoutInsert(f.col, f.idx + 1, below_id); - p.splitBelow(p.active, below); + const f = layout.findPane(p, p.active).?; + layout.insert(p, f.col, f.idx + 1, below_id); + layout.splitBelow(p, p.active, below); p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} @@ -1344,19 +1075,12 @@ test "an untouched tagline ends where its layout column's widest one does" { try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); const above_tag = try p.tagText(p.scratch.allocator(), above); const below_tag = try p.tagText(p.scratch.allocator(), below); - // one column, so the tails end together — and past the pad, at the long - // path, which is the whole point (equal at tw - tag_right_pad would prove - // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); const tail = " Save New Newtty Del Filter"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); - // A voter too wide for the pane is counted AT the pane's edge, not - // dropped: dropping it is a threshold, and one column of resize either - // side of the fit would move every tagline in the column by the whole pad - // while you drag the window edge. One column in, one column out. p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} const fits = (try p.tagText(p.scratch.allocator(), above)).len; @@ -1366,17 +1090,10 @@ test "an untouched tagline ends where its layout column's widest one does" { p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - // Touching the widest tag freezes ITS gap and must move nobody: it goes on - // voting with the end it was frozen at, however much is typed after the - // builtins. (A plain click seeds the tail, so the alternative is every - // other tagline in the column snapping left the moment you click one.) p.seedTail(below); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); - // A pane squeezed off the bottom is not drawn, so it stops voting and the - // column falls back to the pad — 2 rows is one tagline and no room for the - // second pane at all. p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); @@ -1400,9 +1117,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); - // The two preceding releases used the generic current default and then the - // Filter tail without Save. Both upgrade, including any saved layout - // padding. const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); @@ -1425,9 +1139,6 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); - // The first dump format included tty mode in the live prefix. Its stored - // cwd still identifies where the editable bytes begin after the mode word - // disappeared from today's tag, so a custom tail must survive that move. terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ @@ -1463,7 +1174,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w` route is unchanged. - const file = try p.hxOpenFileContent(""); + const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(file), legacy_file_pane_tail, @@ -1490,10 +1201,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); - // Before renderer choices appeared in the live prefix, image dumps began - // with only `img PATH`. Their custom tails still migrate through the - // pane-specific legacy-prefix recognizer. - const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); + const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, @@ -1513,18 +1221,18 @@ test "Joincol folds the active column into its right neighbor, keeping its panes const right = p.freeSlot().?; _ = try p.newShell(right, ""); - try std.testing.expect(p.layoutSplitColumn(0, right, false)); + try std.testing.expect(layout.splitColumn(p, 0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - p.joinCol(); + layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); - const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane; - const rf = p.layoutFindTerm(right) orelse return error.LostRightPane; + const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; + const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - p.joinCol(); // no right neighbor left: inert + layout.joinCol(p); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1558,37 +1266,32 @@ test "an unsaved file marker sits between its path and builtins until Save" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); + const pane = try p.setTestFile("before\n"); const clean = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, clean, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try p.tagText(p.scratch.allocator(), pane); - const marker_at = std.mem.indexOf(u8, dirty, "/hxcase.txt *") orelse return error.MissingDirtyMarker; + const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); - // DRAINED FIRST, and the drain is the point rather than ceremony: the - // marker now clears when the write LANDS, not when the effect is queued. - // This test used to pass without it, which is exactly what was wrong — a - // save the host could not do cleared the marker anyway. No frame is - // affected, because `pump` drains before it renders. while (p.nextEffect()) |effect| p.perform(effect); const saved = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, saved, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); - file_pane.changed(p, 0, "external\n"); + panes.File.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); const output = try p.tagText(p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, output, "/hxcase.txt *") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } test "unknown Exec from an image writes to a terminal in the image directory" { @@ -1619,14 +1322,11 @@ test "unknown Exec from an image writes to a terminal in the image directory" { try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } -/// The host's tty query, as a test double: which panes a program is holding, -/// and how many times the core actually bothered to ask. The count is the -/// laziness contract — nothing but a command line about to be typed may ask. const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, - const vtable: Host.VTable = .{ .pull_tty_taken = answer }; + const vtable: Host.VTable = .{ .tty_taken = answer }; fn install(f: *FakeTtyQuery, p: *Pardes) void { p.host = .{ .ctx = f, .vtable = &vtable }; @@ -1675,9 +1375,6 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); // ...in the directory the command was about, which is the taken pane's own try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); - // A plain/unsupported shell has no OSC 133 B to wait for. Successful - // fork acknowledgement opens the gate and the pty itself buffers input - // until that child reads it. try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { @@ -1695,24 +1392,25 @@ test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); - p.deinitPane(p.panes[0].?); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; const source = "\x00embedded image bytes\xff"; const raw = try p.image_gpa.dupe(u8, source); - const pane = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { + const pane = panes.Image.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; - pane.image.?.petscii = true; + pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); - const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); - defer dump.free(gpa, first); + var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); + defer first_dump.deinit(); + const first = first_dump.value; try std.testing.expect(first.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette); try std.testing.expect(!first.panes[0].image.?.ascii); @@ -1721,7 +1419,7 @@ test "image dump restores source bytes renderer choices and exact custom tail" { const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const restored_pane = restored.panes[0].?; - try std.testing.expect(restored_pane.image.?.petscii); + try std.testing.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag_init); @@ -1729,8 +1427,9 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try restored.dumpState(); - const redump = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); - defer dump.free(gpa, redump); + var parsed = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); + defer parsed.deinit(); + const redump = parsed.value; const encoded = redump.panes[0].image.?.bytes_b64; const decoded = try dump.decodeBytes(gpa, encoded); defer gpa.free(decoded); @@ -1742,9 +1441,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" { } test "Exec from a document pane skips an occupied terminal in its directory and spawns" { - // The test above this pair ("unknown Exec from an image...") is the same - // setup with the terminal at its prompt, and it reuses pane 0. The single - // difference here is the verdict. const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); @@ -1795,16 +1491,8 @@ test "a Look on a directory does not type ls into an occupied terminal" { defer p.deinit(); while (p.nextEffect()) |_| {} - // /tmp rather than a made-up name: the look resolves against the real - // filesystem, so the directory has to exist for this arm to be reached — - // and in the spelling `resolve` HANDS BACK, which is the realpath. `/tmp` - // is itself on Linux and a symlink to `/private/tmp` on Darwin, and the - // `.dir` arm matches a pane by `cwdSlice()` against that realpath, on the - // documented invariant that pane paths are canonical (see `lookAt`). A - // literal "/tmp" therefore matched nothing on a Mac and forked a second - // terminal for a directory that already had one. var realbuf: [4096]u8 = undefined; - const tmp = look.resolve("/tmp", "/", &realbuf).dir; + const tmp = look.resolve(null, "/tmp", "/", &realbuf).dir; p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); @@ -1885,10 +1573,6 @@ test "the host is asked about a tty only where a command line is about to go" { p.setCwd(1, "/tmp/pardes-lazy-b"); p.setCwd(2, "/tmp/pardes-lazy-c"); - // A frame is a frame: rendering, typing, moving the mouse and resizing ask - // nobody anything. This is the whole point of the query being a pull — the - // probe it runs walks /proc, and it used to run for every pane of every - // frame to answer a question only Exec and Look ever ask. _ = try p.render(frame.allocator()); p.update(.{ .key = .{ .cp = 'x' } }); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 4, .row = 4 } }); @@ -1902,10 +1586,6 @@ test "the host is asked about a tty only where a command line is about to go" { while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); - // ...and the fallback scan asks only about the panes that could possibly - // answer yes: the cwd comparison is free and comes first, so the two shells - // sitting in other directories cost nothing. Pane 0 is asked a second time - // because it IS on the directory the command was about. host.taken[0] = true; host.asked = 0; _ = p.execute(0, "echo lazy-again"); @@ -1920,14 +1600,14 @@ test "New opens an empty scratch below the caller, inheriting its directory" { const source: usize = 2; // the right column; active starts in the left p.setCwd(source, "/tmp/pardes-scratch-dir"); - const source_col = p.layoutFindTerm(source).?.col; + const source_col = layout.findPane(p, source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); // no shell IO: the scratch is created in-core, focused, below the caller while (p.nextEffect()) |_| {} const id = p.active; try std.testing.expect(id != source); - try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); + try std.testing.expectEqual(source_col, layout.findPane(p, id).?.col); const np = p.panes[id].?; try std.testing.expect(np.file.?.output != null); // an output buffer, empty try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); @@ -1935,6 +1615,203 @@ test "New opens an empty scratch below the caller, inheriting its directory" { try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); p.setCwd(source, "/tmp/pardes-moved"); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + try p.removePane(source); + p.sync(); + try std.testing.expect(np.cwd == .owned); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); + p.sync(); + try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); +} + +test "owned cwd preserves long paths aliases and failed updates" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + var path: [3072]u8 = @splat('d'); + path[0] = '/'; + for (1..12) |i| path[i * 256] = '/'; + p.setCwd(0, &path); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(pane.cwdSlice().ptr != &path); + const original = pane.cwdSlice(); + try pane.setOwnedCwd(original); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + const effects = p.effects_len; + const free = p.freeSlot().?; + try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); + try std.testing.expect(p.panes[free] == null); + p.saveTo(0, "file.txt"); + try std.testing.expectEqual(effects, p.effects_len); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); + + const current = pane.cwdSlice(); + allocator.fail_index = allocator.alloc_index; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); + try std.testing.expectEqualStrings(&path, pane.cwdSlice()); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); + allocator.fail_index = std.math.maxInt(usize); + p.setCwd(0, "/replacement"); + try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); + const allocations = allocator.allocations; + p.setCwd(0, "/replacement"); + try std.testing.expectEqual(allocations, allocator.allocations); + pane.clearCwd(); + try std.testing.expect(pane.cwd == .none); + try std.testing.expectEqualStrings("", pane.cwdSlice()); +} + +test "owned cwd close copies are transactional across inherited chains" { + for (0..2) |failed_copy| { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + const parent = try p.setTestFile("body\n"); + p.gpa.free(parent.file.?.path); + parent.file.?.path = try p.gpa.dupe(u8, "/work/project/source.zig"); + p.newScratchBelow(0); + const first_id = p.active; + const first = p.panes[first_id].?; + p.newScratchBelow(0); + const second = p.panes[p.active].?; + p.newScratchBelow(first_id); + const grandchild = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + p.parkPendingWrite(0, "queued"); + const pending = p.pending_write[0].?.bytes; + const columns = p.col_panes; + const counts = p.col_n; + const active = p.active; + const live_bytes = allocator.allocated_bytes - allocator.freed_bytes; + allocator.fail_index = allocator.alloc_index + failed_copy; + try std.testing.expectError(error.OutOfMemory, p.removePane(0)); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, first.cwd.inherited); + try std.testing.expectEqual(parent, second.cwd.inherited); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(active, p.active); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expectEqual(pending.ptr, p.pending_write[0].?.bytes.ptr); + try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes); + + allocator.fail_index = std.math.maxInt(usize); + try p.removePane(0); + try std.testing.expect(p.panes[0] == null); + try std.testing.expect(first.cwd == .owned and second.cwd == .owned); + try std.testing.expectEqual(first, grandchild.cwd.inherited); + try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr); + try p.removePane(first_id); + try std.testing.expect(grandchild.cwd == .owned); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(second)); + try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(grandchild)); + } +} + +test "owned cwd column close allocates every copy before removing any pane" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/first"); + p.setCwd(1, "/second"); + const first_child = try p.newDocPane(3); + const second_child = try p.newDocPane(4); + first_child.cwd = .{ .inherited = p.panes[0].? }; + second_child.cwd = .{ .inherited = p.panes[1].? }; + while (p.nextEffect()) |_| {} + const before = p.panes; + const columns = p.col_panes; + const counts = p.col_n; + allocator.fail_index = allocator.alloc_index + 1; + try std.testing.expectError(error.OutOfMemory, p.removeColumn(0)); + for (before, p.panes) |old, current| try std.testing.expectEqual(old, current); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expect(first_child.cwd == .inherited and second_child.cwd == .inherited); + allocator.fail_index = std.math.maxInt(usize); + try p.removeColumn(0); + try std.testing.expect(p.panes[0] == null and p.panes[1] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/first", first_child.cwdSlice()); + try std.testing.expectEqualStrings("/second", second_child.cwdSlice()); +} + +test "owned cwd EOF failure retains the pane and Del retries its close" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.presentation.enabled = false; + p.setCwd(0, "/exited"); + const parent = p.panes[0].?; + p.newScratchBelow(0); + const child = p.panes[p.active].?; + p.sync(); + while (p.nextEffect()) |_| {} + allocator.fail_index = allocator.alloc_index; + p.update(.{ .eof = .{ .pane = 0 } }); + try std.testing.expectEqual(parent, p.panes[0].?); + try std.testing.expectEqual(parent, child.cwd.inherited); + try std.testing.expectEqual(Pane.Mode.normal, parent.mode); + try std.testing.expect(std.mem.indexOf(u8, parent.msg[0..parent.msg_len], "Del retries close") != null); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(p.executeBuiltinLine(0, "Del")); + try std.testing.expect(p.panes[0] == null); + p.sync(); + p.sync(); + try std.testing.expectEqualStrings("/exited", child.cwdSlice()); +} + +test "owned cwd restore either copies the directory or preserves the old core" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + p.setCwd(0, "/retained/terminal/directory"); + try p.dumpState(); + const original = p.panes[0].?; + var completed = false; + for (0..256) |failure| { + allocator.has_induced_failure = false; + allocator.fail_index = allocator.alloc_index + failure; + const replacement = p.restore(p.dump_out.?) catch { + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); + continue; + }; + defer replacement.deinit(); + try std.testing.expectEqualStrings("/retained/terminal/directory", replacement.panes[0].?.cwdSlice()); + try std.testing.expectEqual(original, p.panes[0].?); + if (!allocator.has_induced_failure) { + completed = true; + break; + } + } + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(completed); +} + +test "owned cwd Save promotion releases the former directory" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.newScratchBelow(0); + const id = p.active; + const pane = p.panes[id].?; + try pane.setOwnedCwd("/old/directory"); + p.saveTo(id, "saved.txt"); + try std.testing.expect(pane.cwd == .none); + try std.testing.expect(pane.file.?.output == null); + try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "Save on a scratch asks for a path in its inherited dir and makes it a file" { @@ -1952,7 +1829,7 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(np.hasSavePrompt()); + try std.testing.expect(np.prompt == .save); try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); // typing the filename and submitting converts it into an ordinary file @@ -1980,7 +1857,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" { while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Save")); - try std.testing.expect(pane.hasSavePrompt()); + try std.testing.expect(pane.prompt == .save); try std.testing.expect(pane.appendTag("log.txt")); p.submitSave(0); @@ -2010,7 +1887,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} - const help: output_pane.Origin = .{ .cmd = .Help }; + const help: panes.Output.Origin = .{ .cmd = .Help }; const id = blk: { for (p.panes, 0..) |slot, i| { const pane = slot orelse continue; @@ -2029,7 +1906,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" // Save leads its tagline now, and the path is REQUIRED: a bare Save asks try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(out.hasSavePrompt()); + try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); p.submitSave(id); @@ -2059,12 +1936,9 @@ test "Save takes the path as an argument, relative to the pane's own directory" p.update(.{ .output = .{ .pane = 0, .bytes = "typed and gone\r\n" } }); while (p.nextEffect()) |_| {} - // an argument answers the question the prompt would have asked, so no - // prompt arms — and a bare name lands under the pane's directory, the way - // a relative word in a look resolves, not under the process's cwd var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt")); - try std.testing.expect(!pane.hasSavePrompt()); + try std.testing.expect(pane.prompt != .save); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, @@ -2087,20 +1961,13 @@ test "Save takes the path as an argument, relative to the pane's own directory" ); try std.testing.expect(pane.isTerminal()); - // ...and a path that cannot be made absolute is refused outright: a shell - // that has not reported a directory has nothing to resolve against, and - // the directory pardes was started in is not a guess worth making - p.panes[0].?.cwd = .none; + p.panes[0].?.clearCwd(); try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save the host could not do leaves the pane dirty" { const gpa = std.testing.allocator; - // A host that refuses every write: a read-only file, a directory that was - // removed under the pane, a full disk. The core cannot tell those apart and - // does not need to — the host says why on the message row, and this is the - // other half, which is that the pane must NOT come clean. const Refusing = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void { const core: *Pardes = @ptrCast(@alignCast(ctx.?)); @@ -2110,17 +1977,13 @@ test "a save the host could not do leaves the pane dirty" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); - p.host = .{ .ctx = p, .vtable = &.{ .push_write_file = Refusing.writeFile } }; + p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.writeFile } }; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - // STILL DIRTY. Until this, `saveFile` marked the pane saved the moment it - // QUEUED the effect, so the tag's ` *` cleared on a save that never - // happened — and `Del` makes no dirty check, so the next click threw the - // edits away with the screen saying they were safe. try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); // ...and the same save against a host that CAN write does come clean, so @@ -2129,7 +1992,7 @@ test "a save the host could not do leaves the pane dirty" { try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); - try std.testing.expectEqualStrings("after\n", p.fallback.get("/hxcase.txt").?); + try std.testing.expectEqualStrings("after\n", p.fallback.get("/test.txt").?); } test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { @@ -2137,15 +2000,15 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("before\n"); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + const pane = try p.setTestFile("before\n"); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt")); // a copy, never a rename: the pane keeps its file, and that file is still // unsaved, so the marker stays where it was - try std.testing.expectEqualStrings("/hxcase.txt", pane.file.?.path); + try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try std.testing.expectEqualStrings( "/tmp/pardes-copy/other.txt", @@ -2158,7 +2021,7 @@ test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { // its own path, spelled out, is the in-place write — nothing is asked of // the host but save_file, and the pane comes clean - try std.testing.expect(p.executeBuiltinLine(0, "Save /hxcase.txt")); + try std.testing.expect(p.executeBuiltinLine(0, "Save /test.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); } @@ -2173,7 +2036,7 @@ test "saves armed in one batch stay with their own panes" { const shell = try p.newShell(tty_id, "/tmp/pardes-batch"); p.setCwd(tty_id, "/tmp/pardes-batch"); p.update(.{ .output = .{ .pane = @intCast(tty_id), .bytes = "shell text\r\n" } }); - const file = try p.hxOpenFileContent("file text\n"); + const file = try p.setTestFile("file text\n"); const file_id = p.paneIdOf(file) orelse return error.MissingFilePane; try std.testing.expect(shell.isTerminal()); try std.testing.expect(tty_id != file_id); @@ -2227,7 +2090,7 @@ test "Save reaches every tagline with text behind it and no other" { defer p.deinit(); while (p.nextEffect()) |_| {} - const out = try p.hxOpenFileContent("build.zig:1:1 pub fn main\n"); + const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); @@ -2257,10 +2120,10 @@ test "Save reaches every tagline with text behind it and no other" { // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; - const img = try image_pane.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); + const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); - try std.testing.expect(!img.hasSavePrompt()); + try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); } @@ -2278,11 +2141,8 @@ test "a save path that names no file is refused before anything is rewritten" { p.sync(); while (p.nextEffect()) |_| {} - // Enter on the bare prefill: a directory is not a file to become, and the - // scratch must still be a scratch afterwards — the alternative is a buffer - // renamed onto a path whose write silently failed try std.testing.expect(p.executeBuiltinLine(id, "Save")); - try std.testing.expect(scratch.hasSavePrompt()); + try std.testing.expect(scratch.prompt == .save); p.submitSave(id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); @@ -2307,9 +2167,9 @@ test "a host with no methods at all is a complete in-process pardes" { // the absent child is SILENT: the bytes are dropped, and nothing appears // on the pane's screen to suggest a program answered - const before = p.panes[0].?.vt.screens.active.cursor.y; + const before = p.panes[0].?.terminal.?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); - try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y); + try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one p.yank = try gpa.dupe(u8, "copied"); @@ -2317,7 +2177,7 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); // ...and a save with no filesystem lands in the virtual one - const doc = try p.hxOpenFileContent("body\n"); + const doc = try p.setTestFile("body\n"); var doc_id: u8 = 0; for (p.panes, 0..) |slot, i| if (slot == doc) { doc_id = @intCast(i); @@ -2332,69 +2192,6 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expect(p.quit); } -const RecordHost = struct { - gpa: std.mem.Allocator, - writes: std.ArrayListUnmanaged(u8) = .empty, - /// What this host would answer a clipboard read with, and whether it was - /// ever asked — a pull must reach exactly one host. - clipboard: []const u8 = "", - asked: usize = 0, - core: ?*Pardes = null, - - const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard }; - - fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { - _ = pane; - const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); - self.writes.appendSlice(self.gpa, bytes) catch {}; - } - - fn readClipboard(ctx: ?*anyopaque) void { - const self: *RecordHost = @ptrCast(@alignCast(ctx.?)); - self.asked += 1; - self.core.?.update(.{ .paste = self.clipboard }); - } - - fn host(self: *RecordHost) Host { - return .{ .ctx = self, .vtable = &vt }; - } -}; - -test "a fan-out host reaches every wrapped host, each with its own state" { - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); - defer p.deinit(); - - var a: RecordHost = .{ .gpa = gpa }; - defer a.writes.deinit(gpa); - var b: RecordHost = .{ .gpa = gpa }; - defer b.writes.deinit(gpa); - - const wrapped = [_]Host{ a.host(), b.host() }; - var fan: Fanout = .init(&wrapped); - p.host = fan.host(); - p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } }); - - try std.testing.expectEqualStrings("echo hi\r", a.writes.items); - try std.testing.expectEqualStrings("echo hi\r", b.writes.items); - - // A PULL reaches ONE host, and the name is what says so. Fanned out, both - // would answer and the core would paste the clipboard twice for one Ctrl-V. - a.core = p; - b.core = p; - a.clipboard = "from-a"; - b.clipboard = "from-b"; - const doc = p.panes[p.active].?; - doc.mode = .normal; - p.perform(.read_clipboard); - try std.testing.expectEqual(@as(usize, 1), a.asked); - try std.testing.expectEqual(@as(usize, 0), b.asked); - - // a method NO wrapped host implements still falls back per-method - p.perform(.{ .open_link = .from("https://example.invalid") }); - try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items); -} - test "a builtin that needs a pane reports capacity failure when every slot is full" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); @@ -2472,35 +2269,32 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].path, request); try std.testing.expect(p.settings.font.pending); try std.testing.expect(p.takeFontRequest() == null); - const fake_track: panel_animation.Track = .{ + const fake_track: layout.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; - p.panel_tracks[p.active] = fake_track; - p.presented_panel_tracks[p.active] = fake_track; - p.panel_presentation_ready = true; + p.presentation.tracks[p.active] = fake_track; + p.presentation.shown_tracks[p.active] = fake_track; + p.presentation.acknowledged = true; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(!p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths); - try std.testing.expectEqual(FontSizeUnit.pixels, p.settings.font.effective_size_unit); - try std.testing.expect(p.panel_tracks[p.active] == null); - try std.testing.expect(p.panel_presentation_pending); + try std.testing.expectEqual(config.Runtime.FontSizeUnit.pixels, p.settings.font.effective_size_unit); + try std.testing.expect(p.presentation.tracks[p.active] == null); + try std.testing.expect(p.presentation.pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); - // Asking for the face already on screen is still a real host round trip, - // but it does not invalidate the frozen layer: its effective tuple did - // not change. A rejected request likewise never reaches acknowledgeFont. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest; - p.panel_tracks[p.active] = fake_track; + p.presentation.tracks[p.active] = fake_track; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); - try std.testing.expect(p.panel_tracks[p.active] != null); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(p.presentation.tracks[p.active] != null); + try std.testing.expect(!p.presentation.pending); // A name nothing answers to changes neither request nor effective state. try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face")); @@ -2539,7 +2333,7 @@ test "restored sessions animate layout changes after bootstrap" { source.dump_out.?, ); defer restored.deinit(); - try std.testing.expectEqual(panel_animation.Transition.slide, restored.settings.panel_transition); + try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -2547,11 +2341,11 @@ test "restored sessions animate layout changes after bootstrap" { const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); - try std.testing.expect(restored.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(restored, 0, 1, false)); restored.sync(); - try std.testing.expect(restored.panel_tracks[0] != null); - try std.testing.expect(restored.panel_tracks[1] != null); + try std.testing.expect(restored.presentation.tracks[0] != null); + try std.testing.expect(restored.presentation.tracks[1] != null); try std.testing.expect(restored.animationActive()); } @@ -2592,12 +2386,6 @@ test "restored terminals keep monotonic nonzero pane identities" { try std.testing.expectEqual(fresh.serial, restored.next_serial); } -/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN -/// declarations rather than a table beside them — a new special key names -/// itself here, and a wrong name is impossible because there is only one. The -/// rest is the printable character; anything else is a compile error, because -/// a private-use codepoint cast to a byte would render as silent garbage in a -/// listing nobody diffs. fn chordName(comptime c: config.Chord) []const u8 { comptime { const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else ""); @@ -2610,18 +2398,6 @@ fn chordName(comptime c: config.Chord) []const u8 { } } -/// Every way to run `b` that is NOT its leader path: the chords and buttons -/// config binds to it, plus the topbar if it has a word up there (row 0 is a -/// click target, and the only shortcut that works in tty mode where SPC -/// belongs to the shell). Walked from config's own tables — window_keys' and -/// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a -/// binding there re-renders here with nothing to keep in step. -/// -/// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n, -/// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers -/// with no word to index by. The day one of them becomes a builtin — a struct -/// in builtins.zig and a `cmd` column beside its binding — it appears here for -/// free, which is the upgrade path rather than a special case here. fn shortcutCount(comptime b: Builtin) comptime_int { var count = 0; for (config.window_keys) |wk| if (wk.cmd == b) { @@ -2644,9 +2420,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { @setEvalBranchQuota(20000); var parts: [shortcutCount(b)][]const u8 = undefined; var part: usize = 0; - // both spellings of the four directional moves: the arrow is a real - // key someone presses, and the table carries it precisely so it is - // discoverable from the builtin as well as the other way round for (config.window_keys) |wk| { if (wk.cmd != b) continue; // the prefix has one spelling and this is it @@ -2661,9 +2434,6 @@ fn shortcuts(comptime b: Builtin) []const u8 { part += 1; } } - // the two acme verbs, a key and a mouse button each. Asked of the - // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look - // and Exec by name, so pointing look_cmd at Grep moves the row. if (config.look_cmd == b) { for (config.look_key) |k| { parts[part] = chordName(k); @@ -2720,21 +2490,6 @@ fn leaderKeys(comptime path: []const u8) []const u8 { } } -/// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that -/// runs it (null: SPC does not reach it), every other way to run it, and the -/// Help line those are rendered into. SORTED BY PATH, so every prefix's -/// subtree is a contiguous run, which is all the two runtime readers need. The -/// matcher asks "exact hit? still a prefix of something?" and Help filters the -/// same rows by the same prefix. A node-and-pointer trie for forty -/// two-character paths would be ceremony. -/// -/// A row per BUILTIN and no longer a row per leader PATH, which is what makes -/// Help the complete index instead of a second builtin listing a superset of -/// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any -/// other and its empty key column is the information: SPC does not reach this -/// one, the last column does — or nothing does, which is also worth knowing. -/// The mid-chord filter is the SAME array read with a non-empty prefix, and it -/// drops those rows by itself because an empty path starts with nothing. const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 }; pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // one pass per builtin per config table, and the insertion sort below is @@ -2751,9 +2506,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { } for (std.enums.values(Builtin), 0..) |b, i| { const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else ""; - // the name column is only padded when something follows it: a row - // whose builtin has no other shortcut ends at the name, so the listing - // carries no trailing whitespace const rest = shortcuts(b); const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest); rows[i] = .{ @@ -2762,9 +2514,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named, }; } - // insertion sort by path: a group sorts right before what extends it, and - // a path-less builtin sorts after every path — DEL is not a path because - // leaderKey only ever stores a printable key const last = "\x7f"; for (1..rows.len) |i| { var j = i; @@ -2777,9 +2526,6 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { break :blk rows; }; -// config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order, -// not a derivation — see it there for why each word is in or out. This is the -// check that a rename cannot silently rot it. comptime { @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); @@ -2792,9 +2538,6 @@ comptime { const WordBounds = struct { lo: usize, hi: usize }; -/// The whitespace-delimited word covering `col` in `str`. The topbar uses the -/// same bounds for pointer feedback and dispatch, so the word that lights up -/// is necessarily the word a middle click will execute. fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; @@ -2822,36 +2565,12 @@ const tutor_text = @embedFile("tutor.txt"); // ---- theme ---- -/// Halfway between two colors, channel by channel. Every selection tint pardes -/// paints is a mix of theme colors — the per-mouse-button ones pull the theme's -/// selection toward one of its own accents, a quarter of the way and so a mix -/// of a mix; the extra cursors' pulls it back toward the page — and renderPane -/// makes seven of those, which is six more than spelling the same three-channel -/// loop out at the call site is worth. fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); return out; } -const TAG_TAIL_CAP = limits.max_tag_tail; // one editable command line; extra input is refused -const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record - -/// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own -/// default cell showing (the native-dark shape); `palette` null = let a child's -/// ANSI indices reach the host untranslated until that terminal enables its -/// theme-keyed Filter. The gutter's move box lives here too: it used to be a -/// pair of module constants, but a theme that wants to be -/// restrained has to be able to turn the accent DOWN, and the box is the one -/// piece of loud chrome on screen. The overlay/drag greys that were also -/// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg -/// spelled a second time, so they read those fields now instead. -/// -/// Selection is ONE pair, `sel_bg`/`sel_fg`, and the rest is arithmetic: the -/// three per-mouse-button tints and the dimmed extra cursors are mixed off it in -/// renderPane rather than named here. A theme author gets their selection colour -/// right and would get four more by accident — and every source gen_themes reads -/// names exactly one selection anyway. pub const Theme = struct { name: []const u8, bg: ?[3]u8, @@ -2872,28 +2591,12 @@ pub const Theme = struct { palette: ?[16][3]u8, }; -/// The three themes pardes ships with, in RING ORDER, which is the one thing -/// the files themselves cannot say: `helix` is index 0 and so what boots, and -/// NextColor walks from here out into the generated ones. Written here rather -/// than in a fourth file because the order IS the information — and it is -/// load-bearing, since test/snapshots/theme.snap captures the first three -/// steps of the ring by their colors. const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; -/// A zig file IS a struct, so the FILES are the list: this walks a container's -/// declarations — each one an imported theme file — and copies its `theme` -/// value into a real Theme. FIELD BY FIELD rather than by plain coercion, -/// because a theme file deliberately imports nothing (it is data, not code) and -/// zig will not coerce a whole anonymous struct into a named one; assigning one -/// field at a time puts each value in a result location that knows the type, -/// which is also what makes a missing field a compile error naming it. -/// -/// A FUNCTION and not a const for the same reason builtins.all is one: it is -/// only ever a signature to whoever walks it, never a value in its own way. fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { @setEvalBranchQuota(400000); @@ -2908,13 +2611,6 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { } } -/// The ring: ours, then every theme tools/gen_themes.zig exported out of the -/// helix and zed sources in vendor/themes (build.zig runs it and hands the -/// result over as a module). Adding one is dropping a file in there — there is -/// no list here to append to, which is the whole point of folding the files. -/// 228 of them: everything helix and zed ship, because "which of these is worth -/// having" is the user's call and not the build's. That length is why ThemeSel -/// exists — NextColor is a browse, not a way to arrive anywhere in particular. pub const themes = fold(curated) ++ fold(@import("generated_themes")); comptime { @@ -2922,13 +2618,6 @@ comptime { @compileError("runtime config theme index no longer fits u16"); } -// Two themes answering to one name is a bug: `Theme ` resolves by name -// and would silently pick whichever came first, and ThemeSel would list the -// loser as a row that does nothing. The generated half cannot collide with -// ITSELF — one file per theme, all imported into one struct, so zig's own -// redeclaration error already catches that — which leaves exactly the cross -// pair to check here, three times 225 rather than 228 squared. This is the -// check that makes helix's `acme.toml` vendored as `acme_helix.toml`. comptime { @setEvalBranchQuota(20000); const ours = fold(curated).len; @@ -2936,23 +2625,6 @@ comptime { @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); } -/// The color roles attached to stable screen geometry. Document backgrounds, -/// syntax, terminal ANSI palettes, and PDF tint colors deliberately are not -/// here: those switch to `theme()` immediately while this small palette moves -/// between themes over a handful of display frames. -/// WHAT THE BOARD BOOTS WITH. An empty buffer is honest and useless: the three words that make -/// this board interesting take an address, and a board's address space is precisely the thing you -/// cannot guess. So the buffer is a tour of it - every address below comes from this repository -/// rather than from memory, which is why they are worth trusting: the two flash figures and the two -/// RAM ones are the linker script's own ORIGINs (`05-zig-p4/build.zig`'s MEMORY block), and the -/// peripheral bases are `DR_REG_*` from ESP-IDF's headers as `05-zig-p4/src/hal` uses them. -/// -/// Each command sits alone on its line because an argument list ends at the last argument - a -/// trailing comment would be `ExtraArgument` - so the notes go above the lines they describe. Run -/// one by putting the cursor on it, `x` to select the line, Tab to execute. -/// -/// EVERY LINE IS SHORT ENOUGH TO RENDER WHOLE, which is asserted rather than eyeballed: see the -/// test below. A tour whose lines wrap is a worse first screen than no tour. const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ @@ -2977,12 +2649,6 @@ const boot_buffer = \\Gpio 33 ; -// Three times in this port a line in that buffer has been one or two characters too long for the -// board's 56-column grid, and every time it was found by reading the die's screen rather than by -// reading the source - which is the expensive way to find a string literal's length. The bound is -// the grid minus the line-number gutter minus a column, and the margin below it is deliberate: -// pinning the exact gutter width would make this test a restatement of the renderer instead of a -// statement about the text. test "every line of the board's boot buffer renders whole" { const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); @@ -3022,18 +2688,15 @@ pub const ChromeTheme = struct { pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; -/// The fade, or its absence, decided at comptime so that `-Dtheme-animation=false` leaves -/// `ChromeTheme.interpolate` unreachable and therefore out of the binary entirely. Every call site -/// below is written against the shared interface and needs no condition of its own. const ChromeAnimation = if (theme_animation) - animation.Transition(ChromeTheme) + layout.Animation.Transition(ChromeTheme) else - animation.Immediate(ChromeTheme); + layout.Animation.Immediate(ChromeTheme); const initial_chrome = ChromeTheme.fromTheme(&themes[0]); // ---- the boundary types ---- @@ -3041,7 +2704,6 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]); pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; pub const FontRole = enum(u8) { body, tagline }; -pub const FontSizeUnit = runtime_cfg.FontSizeUnit; pub const CellStyle = struct { fg: Color = .default, @@ -3057,9 +2719,6 @@ pub const CellStyle = struct { font_role: FontRole = .body, }; -/// One surface cell. `default = true` means "never painted this frame": the -/// shell renders it as the terminal's default cell (vaxis clear semantics). -// EFFECT_CODE_ASCII_DIFF_BEGIN pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, @@ -3070,9 +2729,6 @@ pub const Cell = struct { return c.text[0..c.len]; } - /// Equality of what a shell can actually present. Bytes past `len` are - /// scratch left by earlier graphemes and must never manufacture a panel - /// diff; an unpainted default cell likewise has no visible style/text. pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool { if (a.default or b.default) return a.default and b.default; return a.len == b.len and @@ -3080,10 +2736,6 @@ pub const Cell = struct { std.meta.eql(a.style, b.style); } - /// The byte an ASCII transition may walk. Default cells are visibly - /// spaces; painted cells opt in only when their complete grapheme is one - /// printable byte. This keeps an intermediate frame valid UTF-8 and - /// prevents a style-only or multi-byte change from churning its glyph. pub fn printableAscii(c: *const Cell) ?u8 { if (c.default) return ' '; if (c.len != 1) return null; @@ -3092,17 +2744,13 @@ pub const Cell = struct { } }; -/// The semantic character part of one old/new panel-cell diff. It lives in -/// the core because every renderer must present the same byte at a given -/// frame. Backends receive the already-composed Cell; none implements this -/// walk or chooses its own punctuation/noise threshold. pub const AsciiDiff = struct { from: u8, to: u8, /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. - pub const max_movement_frames = panel_animation.ascii_max_movement_frames; + pub const max_movement_frames = layout.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; @@ -3123,11 +2771,6 @@ pub const AsciiDiff = struct { return diff.movementFrames() + 1; } - /// Move through the u8 range with integer ease-in-out over exactly - /// `movementFrames` samples. A byte creeps at both ends and crosses the - /// middle of its distance in a few large skips, inside the same frame - /// count the old constant one-byte-per-frame walk took. Keeping this - /// integer-only makes every backend receive the same character. pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { const movements: u32 = diff.movementFrames(); const at: u32 = @min(@as(u32, frame), movements); @@ -3135,18 +2778,10 @@ pub const AsciiDiff = struct { return if (diff.from < diff.to) diff.from + delta else diff.from - delta; } - /// `distance * smootherstep(at / movements)`, rounded, without touching - /// floating point. Endpoints are exact — zero at frame zero, the whole - /// distance at the last movement — and the curve is monotonic, so a byte - /// never walks backwards between samples. fn easedDistance(span: u8, movements: u32, at: u32) u32 { if (movements == 0 or at >= movements) return span; const n: u64 = at; const d: u64 = movements; - // Quintic smootherstep as one exact fraction: n^3 (10 d^2 + 6 n^2 - - // 15 d n) over d^5. The positive terms are summed first because - // 10 d^2 + 6 n^2 >= 15 d n for every n <= d: unsigned arithmetic must - // never see the intermediate go below zero. const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); const denominator = d * d * d * d * d; return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); @@ -3157,10 +2792,6 @@ pub const AsciiDiff = struct { } }; -/// One core-owned classification per cell in the frozen old/new grid. A -/// visual-only diff still matters to dissolve, but PanelAscii only walks the -/// `.ascii` case. That distinction fixes the old effect's habit of replacing -/// unchanged glyphs merely because their colour or other style changed. pub const PanelCellDiff = union(enum) { unchanged, visual, @@ -3176,7 +2807,6 @@ pub const PanelCellDiff = union(enum) { return diff != .unchanged; } }; -// EFFECT_CODE_ASCII_DIFF_END test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; @@ -3199,9 +2829,6 @@ test "ASCII cell diffs ease long byte walks in both directions" { var high: Cell = .{ .default = false }; high.text[0] = 'F'; - // Ease-in-out inside exactly the five movement frames the old constant - // walk used: the first sample holds, the middle crosses two values at a - // time, and the endpoint is exact. const rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); @@ -3263,10 +2890,6 @@ test "ASCII diff classification skips stable and non-ASCII glyphs" { try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); } -/// One generation of a pixel attachment. `serial` identifies the pane for its -/// whole lifetime; `revision` identifies pixels rendered later by that same -/// pane (for example, a different PDF page or zoom level). Backends must use -/// both: pane slots are reused, while a live pane may replace its pixels. pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, page: u32, @@ -3284,8 +2907,8 @@ pub const ImageCacheKey = if (pdf_enabled) struct { } }; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; +const PdfFitMode = panes.Pdf.FitMode; +const PdfTintMode = panes.Pdf.TintMode; /// Dynamic placement exists only for native PDF pages. Static image panes need /// only their pane identity, so feature-off builds carry a zero-bit payload. @@ -3302,9 +2925,6 @@ pub const NativePlacement = if (pdf_enabled) struct { pixel_offset_y: f32 = 0, } else struct {}; -/// A pixel image riding the surface: the shell transmits/places it over the -/// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). -/// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or @@ -3349,7 +2969,7 @@ test "pixel attachment cache key follows both pane lifetime and rendered revisio try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); - try std.testing.expectEqual(@as(usize, 0), @sizeOf(pdf_pane.PointerDrag)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(panes.Pdf.PointerDrag)); } } @@ -3382,23 +3002,14 @@ pub const Surface = struct { cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - /// Pixel attachments are the exact visible set. PDFs can legally contain - /// arbitrarily short pages, so no fixed page-count array can represent a - /// viewport without occasionally dropping an intersecting page. images: []?ImagePlace = &.{}, nimages: usize = 0, - /// Active pane transitions, keyed by the stable pane serial carried in - /// each record. GUI shells evaluate these in shaders; the TTY remaps this - /// same frame's cells through its grid compositor. - panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined, + panel_tracks: [MAX_PANES * 2]layout.Track = undefined, npanel_tracks: usize = 0, - /// Frozen canonical cells from before the current content/lifecycle - /// transition, plus the core's semantic old/new classifications. Both are - /// core-owned and remain stable until every associated track has finished. previous_cells: []const Cell = &.{}, cell_diffs: []const PanelCellDiff = &.{}, - pub fn panelTracks(s: *const Surface) []const panel_animation.Track { + pub fn panelTracks(s: *const Surface) []const layout.Track { return s.panel_tracks[0..s.npanel_tracks]; } @@ -3431,34 +3042,12 @@ pub const Surface = struct { c.default = false; } - /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the - /// column after the last written cell. Wide glyphs take two cells. - /// - /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever - /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can - /// be any bytes at all, and a search row splices both. std's unchecked - /// iterator panics on a bad start byte, so decode by hand and paint one - /// U+FFFD per undecodable byte (what a terminal does). fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; - // ASCII FAST PATH. Printable ASCII is one byte, one cell, one column, and the general - // path below reaches that answer through a UTF-8 length, a decode, a freshly - // constructed grapheme iterator, a slice validation and a width lookup - per character. - // That made this function 26% of a keystroke when profiled in the ESP32-P4's - // configuration (40x12, no tree-sitter), which is the largest single item there. - // - // The guard on the NEXT byte is what makes it correct rather than merely fast: an ASCII - // base joins a following combining mark, ZWJ or spacing mark into ONE cluster, and every - // scalar that can do that is non-ASCII. So an ASCII byte followed by another ASCII byte - // (or by nothing) is a complete grapheme cluster on its own. Same condition - // `modal.nextGrapheme` uses, for the same reason. - // - // `\t`, `\r` and the C0 controls are excluded by the range test and keep their existing - // handling below; DEL is excluded too. { const b = text[i]; if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { @@ -3479,10 +3068,6 @@ pub const Surface = struct { var cp_slice: []const u8 = "\u{FFFD}"; var consumed: usize = 1; if (decoded != null) { - // A surface cell is a grapheme, not a codepoint. Keeping the - // complete cluster makes combining marks visible and keeps ZWJ, - // modifier, flag and Indic sequences in the same screen cell - // that cursor/edit math treats as one unit. var git = uucode.grapheme.utf8Iterator(text[i..]); if (git.nextGrapheme()) |g| { const candidate = text[i .. i + g.end]; @@ -3498,10 +3083,6 @@ pub const Surface = struct { i += consumed; var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; - // A Surface cell is already positioned, not a terminal byte - // stream. Expand tabs here so every Surface consumer — GUI, tty, - // web, and macOS — sees the same configured run of blank cells - // instead of asking its font for a control-character glyph. if (cp == '\t') { const spaces = @min(config.tab_width, end - col); s.fill(col, y, spaces, 1, style); @@ -3517,18 +3098,7 @@ pub const Surface = struct { 1 else @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); - // a DOUBLE-width glyph with one column left is not drawn at all. - // Writing it puts one cell in the surface and two on the glass, and - // when that column is the screen's last the terminal wraps the tail - // onto the next row — where our own model says "space", so the diff - // render never repaints it and the smear outlives the frame. A - // blank at the edge is what every terminal does with the same - // problem. Reachable from any byte cut through wide text: hscroll's - // and soft wrap's both. if (width == 2 and col + 1 >= end) break; - // The cross-host Cell ABI has seven payload bytes. Preserve a valid - // codepoint prefix when a modern emoji cluster is longer; its full - // width and edit boundary still come from the complete cluster. var shown = cp_slice; if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { const cap = @typeInfo(@FieldType(Cell, "text")).array.len; @@ -3608,10 +3178,6 @@ test "surface print keeps combining and wide graphemes in their display cells" { } test "the ASCII fast path in surface print paints what the general arm paints" { - // The fast path skips a UTF-8 length, a decode, a grapheme iterator, a slice validation and a - // width lookup, so it can only be judged against those: the reference below is `print` with the - // fast-path block deleted and nothing else changed. Both routes paint into equally sized - // surfaces and every cell plus the returned column must match. const ref = struct { fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; @@ -3703,11 +3269,6 @@ test "the ASCII fast path in surface print paints what the general arm paints" { } }; - // The scalars that extend an ASCII base into ONE cluster - a combining mark, a ZWJ sequence, a - // spacing mark, a variation selector - are exactly what the guard on the next byte exists for. - // Wide glyphs check the two-cell accounting either side of the fast path, and the three invalid - // sequences check that a bad start byte, a truncated tail and a bad continuation each still - // become one U+FFFD per undecodable byte instead of being swallowed. const neighbours = [_][]const u8{ "", "x", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", @@ -3738,7 +3299,7 @@ test "insert and normal modes edit complete Unicode graphemes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); + const pane = try p.setTestFile("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); pane.mode = .insert; pane.cur_col = 22; // on z, after the CJK grapheme @@ -3760,7 +3321,7 @@ test "insert and normal modes edit complete Unicode graphemes" { p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 3; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.normalReplaceChar(pane, 'λ'); @@ -3768,7 +3329,7 @@ test "insert and normal modes edit complete Unicode graphemes" { try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); - file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); + panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 0; pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true }; p.normalReplaceChar(pane, 'λ'); @@ -3777,11 +3338,87 @@ test "insert and normal modes edit complete Unicode graphemes" { try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); } +test "flat text movement and selection replay need no scratch rows" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const row_text = "one (two) λ\r\n"; + const text = try gpa.alloc(u8, 4096 * row_text.len); + defer gpa.free(text); + for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text); + const pane = try p.setTestFile(text); + _ = try panes.File.lineIndex(gpa, &pane.file.?); + var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + p.scratch.deinit(); + p.scratch = .init(denied.allocator()); + + pane.cur_row = 4094; + p.handleNormal(pane, .{ .cp = 'l' }); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + p.handleNormal(pane, .{ .cp = 'h' }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } }); + try std.testing.expectEqual(@as(i32, 2), pane.cur_col); + try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); + + pane.vsel.active = false; + pane.cur_row = 1024; + pane.cur_col = 0; + pane.nsel = 1; + pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; + p.handleNormal(pane, .{ .cp = 'l' }); + p.handleNormal(pane, .{ .cp = 'j' }); + try std.testing.expectEqual(@as(u8, 1), pane.nsel); + try std.testing.expectEqual(@as(i32, 1025), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row); + try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col); + try std.testing.expect(!denied.has_induced_failure); + try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); + + pane.nsel = 0; + pane.cur_col = 4; + p.executeNormalAction(pane, .match_bracket); + try std.testing.expect(denied.has_induced_failure); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); + denied.fail_index = std.math.maxInt(usize); + p.executeNormalAction(pane, .match_bracket); + try std.testing.expectEqual(@as(i32, 8), pane.cur_col); +} + +test "flat text replacement preserves a terminal fragment origin and direction" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + p.update(.{ .output = .{ .pane = 0, .bytes = "first\r\nsecond\r\nthird\r\n界a\r\n" } }); + const pane = p.panes[0].?; + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 3; + pane.cur_col = 0; + pane.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true }; + p.normalReplaceChar(pane, 'λ'); + try std.testing.expectEqual(@as(i32, 3), pane.ovl.?.row); + try std.testing.expectEqualStrings("λλ", pane.ovl.?.text); + try std.testing.expectEqual(@as(i32, 3), pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + try std.testing.expectEqual(@as(i32, 3), pane.vsel.row); + try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); + try std.testing.expect(pane.vsel.active and pane.vsel.explicit); + const lines = try p.paneCursorLines(pane); + try std.testing.expectEqualStrings("first", lines[0]); + try std.testing.expectEqualStrings("λλ", lines[3]); + const flat = try p.flatSurface(pane); + try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n")); +} + test "Unicode display cells map back to body and tag byte cursors" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("a界e\u{301}z\n"); + const pane = try p.setTestFile("a界e\u{301}z\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "界e\u{301}.txt"); @@ -3822,7 +3459,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("\treturn x\n"); + const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @memset(f.highlights, @intFromEnum(syntax.Syn.none)); @@ -3848,13 +3485,9 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, 3), pane.cur_col); - // Display-column conversion must not turn a click in the blank space - // after EOL into a click on the final byte. Besides moving the modal - // cursor, that would make a no-drag Look expand the last word instead of - // remaining inert over blank space. const line = modal.lineSlice(f.content, 0); const virtual: u16 = 3; - const blank_x = text_x + @as(u16, @intCast(file_pane.displayWidth(line))) + virtual; + const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, @intCast(line.len + @as(usize, virtual))), pane.cur_col); @@ -3868,7 +3501,7 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column // The immediately-adjacent EOL cell and an in-line separator are blank // too. Pointer expansion must not lean left into the preceding word. - const adjacent: i32 = @intCast(config.PREFIX_W + file_pane.displayWidth(line)); + const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); @@ -3882,11 +3515,11 @@ test "Look hover waits without mutating the pane and input cancels it" { const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} - const pane = try p.hxOpenFileContent("alpha beta gamma\n"); + const pane = try p.setTestFile("alpha beta gamma\n"); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); - try std.testing.expect(p.layoutSplitColumn(0, other_id, false)); + try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); p.sync(); while (p.nextEffect()) |_| {} @@ -3941,9 +3574,6 @@ test "Look hover waits without mutating the pane and input cancels it" { p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } }); try std.testing.expect(p.look_hover_preview != null); - // A real leave cannot be represented as an out-of-range motion: motion - // coordinates are deliberately clamped for drags. It also clears the - // ordinary resize-handle hint, not only this Look-specific preview. const seam_x = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } }); _ = frame.reset(.retain_capacity); @@ -3956,9 +3586,6 @@ test "Look hover waits without mutating the pane and input cancels it" { const left_seam = try p.render(frame.allocator()); try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme())); - // Blank space after EOL has a pointer cell but no Look operand. Ageing it - // to completion must turn the animation clock back off without drawing a - // misleading one-cell preview. const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } }); for (0..delay) |_| p.update(.tick); @@ -4020,7 +3647,7 @@ test "plain left click clears explicit modal selection" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 }); defer p.deinit(); - const pane = try p.hxOpenFileContent("abcdef\n"); + const pane = try p.setTestFile("abcdef\n"); pane.cur_row = 0; pane.cur_col = 5; pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; @@ -4044,7 +3671,7 @@ test "plain left click clears explicit modal selection" { test "selection drag and queued release do not enter panel pending state" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true }); defer p.deinit(); - const pane = try p.hxOpenFileContent("alpha beta\n"); + const pane = try p.setTestFile("alpha beta\n"); p.acknowledgePanelPresentation(&.{}); const rect = p.rects[0]; const x = rect.x + config.GUTTER + config.PREFIX_W + 1; @@ -4052,7 +3679,7 @@ test "selection drag and queued release do not enter panel pending state" { p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } }); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(!p.presentation.pending); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); @@ -4066,10 +3693,6 @@ pub const Mouse = struct { kind: Kind, col: u16, row: u16, - /// Ctrl held during the click. Only the left press reads it (ctrl-click = - /// goto-definition, the one chord every editor with an LSP has); every - /// other button ignores it, because acme's button semantics are already - /// the vocabulary here and modifiers are not part of it. ctrl: bool = false, }; @@ -4078,9 +3701,6 @@ pub const Key = struct { text: []const u8 = "", ctrl: bool = false, alt: bool = false, - /// Only consulted for keys whose codepoint does NOT already carry the - /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `hit()` - /// ignores this field unless a binding explicitly asks for it. shift: bool = false, pub const enter: u21 = 0x0D; @@ -4099,42 +3719,21 @@ pub const Key = struct { pub const delete: u21 = 0xF0009; }; -/// Does this press match a binding? Every keymap test in the core goes through -/// here, so the modifier rules are written once instead of once per modifier -/// (the three is/isC/isA predicates this replaces each spelled out the same -/// comparison with a different pair of negations). -/// -/// A binding is a LIST: most have two spellings that must reach the same arm — -/// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do -/// that at every call site is now one loop here. fn hit(key: Key, binding: []const config.Chord) bool { for (binding) |c| { if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue; - // shift is carried in the codepoint for anything printable (`A` is - // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for - // it — otherwise every letter binding would newly demand shift be up if (c.shift and !key.shift) continue; return true; } return false; } -/// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT -/// button's slot: where a sweep lands and where the left half of every acme -/// chord reads its selection from. Named rather than the bare 0 it used to be -/// so that config.select_button is genuinely the only place the choice is -/// made. const sel_slot = @intFromEnum(config.select_button); -/// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config -/// spells those as bare codepoints rather than Chords because pardes stores -/// the codepoint itself in pane.pending until the next key completes the -/// sequence — so this is the one place the two shapes meet, and it is the same -/// comparison the stored byte gets a few lines later. fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } -fn roleBindingName(comptime role: normal_input.Role) []const u8 { +fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", @@ -4150,18 +3749,13 @@ fn roleBindingName(comptime role: normal_input.Role) []const u8 { }; } -/// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry -/// several roles (`h` is both move-left and `gh`'s line-start); the pure -/// normal_input parser chooses among them from its explicit prefix state. -/// Both text panes and PDF panes call this exact function before adapting the -/// resulting semantic Action. -fn normalInput(key: Key) normal_input.Input { - var out: normal_input.Input = .{ +fn normalInput(key: Key) modal.Normal.Input { + var out: modal.Normal.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; - inline for (std.enums.values(normal_input.Role)) |role| { + inline for (std.enums.values(modal.Normal.Role)) |role| { const name = comptime roleBindingName(role); if (!@hasDecl(config, name)) @compileError("normal input role has no config binding: " ++ name); @@ -4190,51 +3784,19 @@ pub const Event = union(enum) { resize: struct { cols: u16, rows: u16, - /// Physical pixels in one grid cell, present only when native PDF - /// placement is compiled in. Defaults keep headless/core callers - /// useful and give terminals which cannot report pixels the - /// conventional 1:2 cell aspect. cell_pixels: CellPixels = .{}, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, - /// a language query the shell ran on a worker has finished. `rows` is - /// `+Search`-format text (see lsp.zig) and is borrowed for this call only, - /// exactly like `output` bytes. An id the core no longer recognises is a - /// stale answer (the pane was closed, or a newer query superseded it) and - /// is dropped. - lsp_resp: struct { id: u32, rows: []const u8 }, - /// A selection-pipe worker finished. Every output is borrowed for this - /// update only; success is atomic, so a failed/nonzero invocation carries - /// no usable outputs and changes nothing. - /// `failure` is borrowed for this update like `outputs`, and is what the - /// core turns into an `+Errors` pane. Null with `success = false` means - /// nobody ever ran it — a host with no `pull_pipe` at all. + lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure = null, }, - /// a file the shell was asked to watch changed on disk; `bytes` are the - /// exact snapshot the host hashed, borrowed for this call like `output`. - /// Text panes adopt a copy; PDF panes reopen the path so MuPDF owns its - /// random-access document. A shell with no filesystem (the browser) or no - /// watcher simply never sends one — nothing in the core waits for it. file_changed: struct { pane: u8, bytes: []const u8 }, - /// Text from the SYSTEM clipboard, borrowed for this call. Two ways in, - /// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard` - /// the core emitted for `SPC p` / `SPC P` / `SPC R`, which decides where - /// it lands; and UNSOLICITED — an outer terminal's bracketed paste, a - /// Cmd-V, the browser's paste event — which means `SPC p`, paste after. - /// Neither touches the default register: that is `y`'s alone (helix). paste: []const u8, - /// One builtin command line, handed to the shell by a pardes launched - /// INSIDE this one (see nested.zig) — `Look /abs/path` and nothing else - /// today. Borrowed for this call exactly like `output` bytes. It comes in - /// as an EVENT rather than a direct executeBuiltinLine call so it gets the - /// trailing sync and the ordinary effect drain: `Look` on a directory - /// emits a `.spawn` the shell has to perform. command: []const u8, /// Native shells may preserve sub-cell wheel distance in physical pixels. /// TTY button events still enter through the ordinary mouse path. @@ -4245,131 +3807,46 @@ pub const Event = union(enum) { /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, - /// ONE FILESYSTEM REQUEST from a process that opened a file under the - /// acme-style control mount (src/acmefs.zig, served by src/fuse.zig). - /// `data` is borrowed for this call exactly like `output` bytes, which is - /// why this — like them — never goes through `postEvent`. The answer - /// leaves as an `Effect.fs_reply` in the same update, so the transport - /// that asked is the one that writes it back: no thread, no waiting and - /// no filesystem knowledge anywhere in here. - fs_req: acmefs.Req, + fs_req: filesystem.Req, }; -/// The longest session name `Effect.attach` can carry. A name is ONE path -/// component under the runtime socket directory (detached/server.zig -/// `socketPath`), so `sun_path`'s 108 bytes cap a usable one far below this; -/// 256 is the width `spawn`'s cwd and `open_link` already reserve, and reusing -/// it is why the new arm costs the effect ring nothing — `save_text`'s -/// {pane, serial, Buf(256)} is still the widest thing in the union. pub const attach_name_max = 256; -/// What `takeAttach` hands the shell: the session to reach for (empty means -/// "whichever one is there") and the pane whose message row a failed connect -/// is reported on, the way `Effect.dump_themes` carries the pane that receives -/// the native host's answer. pub const AttachRequest = struct { pane: u8, name: []const u8 }; -/// A pane's pty bytes waiting for room in the effect ring. Core-owned (the -/// `Event.paste` slice they came from is borrowed for one `update` only), and -/// freed the moment `off` reaches the end or the pane goes away. pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; -/// WHAT `pty/ctl`'s `sig` VERB CAN SEND. Named rather than numeric because the -/// core is freestanding: it has no `SIGINT` to name and a number written here -/// would be one platform's number travelling to a host that may not share it. -/// Five, and deliberately not the whole of signal(7): these are the ones a -/// human at a terminal already has a key or a `kill` for, and every one of -/// them means something to a program on a tty. `sig USR1` at a shell is a -/// message to a daemon, not a terminal operation, and nothing asked for it. pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. +pub const WatchMode = enum { reconcile, baseline_disk }; +pub const effect_path_cap = 256; + pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(256) }, + spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, - /// Deliver a signal to whatever is on this pane's tty — `pty/ctl`'s - /// `sig INT`, i.e. the ^C a script cannot type because ^C is not a byte - /// the pty would interpret on its own. The only genuinely new capability - /// the `pty/` directory added: `spawn` and `resize_pty` above were already - /// here, so `exec` and `winsize` are those two acquiring a name. signal_pty: struct { pane: u8, sig: PtySignal }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, - /// Write this pane's text to a path WITHOUT converting the pane — a - /// terminal's scrollback, a results buffer's rows, a file copied elsewhere. - /// The path travels HERE, bounded exactly like a spawn's cwd, so two saves - /// armed in one batch cannot cross; the BYTES are read off the pane when - /// this is performed, the way save_file reads a file pane. `serial` is the - /// pane it was armed for: a slot freed and reused before the drain writes - /// nothing rather than another pane's text to this path. - save_text: struct { pane: u8, serial: u32, path: Buf(256) }, - /// a serialized state dump is ready in core.dump_out; write it to the - /// path dump.outPath resolves (acme-style: another instance loads it - /// with -l, or the Restore builtin loads it into this one) + save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) }, write_dump, - /// mirror the yank register OUT to the system clipboard; the shell reads - /// it off the core (OSC 52 out, SDL_SetClipboardText, NSPasteboard). - /// Emitted ONLY by the explicit clipboard commands — see setClipboard. set_clipboard, - /// ...and the other direction: ask the shell to READ the system clipboard. - /// The answer comes back as an ordinary `Event.paste`, which the core - /// routes to whichever of `SPC p` / `SPC P` / `SPC R` asked for it - /// (Pardes.clip_pending). No payload: the bytes travel in the event. read_clipboard, - /// answer a language query OFF the event loop and post the rows back as an - /// `lsp_resp` Event. The shell reads the file's path and content off the - /// core (like save_file) and must SNAPSHOT them before the worker starts — - /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, - /// start (`on`) or stop watching this pane's file on disk. Starting, the - /// shell reads the path off the core exactly like save_file does; stopping - /// carries nothing, because by the time an `off` is drained the pane is - /// already freed — the shell remembers what it watches per pane id. - /// Real text files and PDFs ask for this; an output buffer has no file - /// behind it. - watch: struct { pane: u8, on: bool }, - /// Load/unload the one runtime theme file watch. The path lives in the - /// core's fixed request buffer; carrying only its generation keeps this - /// already-large effect ring compact. + watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, - /// The answer to an `Event.fs_req`. The bytes are NOT in here: `payload` - /// says where they live (a staging buffer in the core, or a range of a - /// pane's live text) and `fsPayload` resolves it during the drain, so a - /// megabyte read costs one `writev` and no copy. `.again` means the core - /// has nothing yet and the transport must ask again later — acme's - /// blocking `event` read, with the waiting left where the kernel's - /// request already is. - fs_reply: acmefs.Reply, - /// `Attach [name]` — hand this frontend's screen to a detached core, the - /// one `pardes --detach [name]` left running; an empty name means "the - /// session that is there". `pane` is where a failed connect is reported. - /// - /// CONNECT FIRST, SWAP SECOND is what the shell owes this, and it is the - /// whole point of the word: the session's socket must be open before - /// anything local is torn down, so an attach that fails leaves this - /// instance running with every pane and every undo intact instead of half - /// dead. Which is also why no host method performs it — see `perform`. + fs_reply: filesystem.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, - /// `Detach` — this frontend leaves; the session and every other frontend - /// carry on. tmux's detach-client, and deliberately NOT the inverse of - /// `attach`: turning a live LOCAL session into a daemon needs setsid and a - /// fork, or closing the terminal takes the session with it. - /// - /// No name travels because there is nobody to name. The word is typed in a - /// frontend that has no core of its own, reaches the daemon as an ordinary - /// `Event.command`, and the daemon routes the effect back to the frontend - /// whose keystroke caused it. `pane` is only for the report a local shell - /// gets instead — `perform`'s null-method arm. detach: struct { pane: u8 }, quit, @@ -4392,22 +3869,6 @@ pub const Effect = union(enum) { } }; -pub const Mode = enum { normal, insert, tty }; - -/// An owned editable buffer and the absolute surface row of its first line. -/// Files use row zero; terminal overlays may begin anywhere in scrollback. -pub const EditText = struct { text: []u8, row0: i32 }; - -/// One mouse selection (block-shaped), per button. c/r are text-area relative; -/// r counts from the tag row (body starts at BOX_H). -pub const Sel = struct { - state: enum { none, dragging, done } = .none, - c0: i32 = 0, - c1: i32 = 0, - r0: i32 = 0, - r1: i32 = 0, -}; - const LookHoverWait = struct { col: u16, row: u16, @@ -4421,15 +3882,7 @@ const LookHoverPreview = struct { row: u16, pane: usize, serial: u32, - /// Null when the operand is the pane's modal selection. The renderer uses - /// that marker to paint the live modal range subtly even while tag/search - /// editing would normally hide it. A kept mouse selection carries its - /// exact screen-space range; an ordinary word carries the click expansion. - sel: ?Sel, - /// A wrapped file word is one logical source span, not one rectangular - /// screen selection. Keeping it in source coordinates lets the renderer - /// paint every visible continuation while Look receives the exact same - /// bytes, including a path which crosses a soft-wrap boundary. + sel: ?Pane.Sel, file_word: ?FileWordSpan = null, }; @@ -4444,7 +3897,7 @@ const PdfWordPreview = if (pdf_enabled) struct { row: u16, pane: usize, serial: u32, - probe: pdf_pane.WordProbe, + probe: panes.Pdf.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); @@ -4452,576 +3905,11 @@ const PdfWordPreview = if (pdf_enabled) struct { } } else void; -/// A modal line selection (helix `x`): whole rows [r0, r1], absolute. -pub const LineSel = struct { - active: bool = false, - r0: i32 = 0, - r1: i32 = 0, -}; - -/// A modal char-range selection: the anchor lives here, the head is the pane -/// cursor. Since the helix motion model landed, EVERY motion leaves one of -/// these — `explicit` separates user-intent selections (v / x / X / terminal -/// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords -/// only act on explicit ones. Mutually exclusive with LineSel. -pub const CharSel = struct { - active: bool = false, - row: i32 = 0, - col: i32 = 0, - explicit: bool = false, -}; - -/// One position in the n/N walk: a look-able span on one row of one pane, -/// inclusive of both columns. Also what the walk REMEMBERS having stood on -/// (Pane.look_at) — see lookStand for why the cursor alone cannot say. -pub const LookSpot = struct { - row: i32, - col0: i32, - col1: i32, -}; - -/// ponytail: at most this many cursors at once. helix's `Selection.ranges` is -/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo -/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only -/// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are -/// simply not created — raise the bound if that ever matters. -pub const MAX_SELS = 64; - -/// One selection range in PANE coordinates: the block-cursor cell and the -/// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel` -/// already are, so a range moves in and out of the primary slot without a -/// conversion. -pub const SelRange = struct { - row: i32, - col: i32, - arow: i32, - acol: i32, - /// this range's own j/k goal column (helix Range::old_visual_position); - /// the PRIMARY's copy is Pane.sticky_col - sticky: i32 = -1, -}; - -const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void; - -/// The emulator's raw-byte dump/replay ring, and NOTHING where there is no pty -/// to read bytes from — same shape as `PdfSlot`, for a much harder reason. It -/// is a MEGABYTE inline in every Pane: on the P4 the whole heap is 384 KiB, so -/// carrying it would make `gpa.create(Pane)` fail before anything could ask -/// for a grid, and `Pardes.init` — which creates a pane unconditionally — -/// could not return. -const ReplaySlot = if (terminal_panes) [TTY_REPLAY_CAP]u8 else void; - -/// The staging buffer for the query replies ghostty computes. Its only writer -/// is term_pane's `ptyReport` callback, which does not exist without an -/// emulator, so `reply_len` there is permanently 0 and `sync` never reads it. -const ReplySlot = if (terminal_panes) [256]u8 else void; - -fn hasPdf(pane: *const Pane) bool { - return if (comptime pdf_enabled) pane.pdf != null else false; -} - -fn hasPdfSelection(pane: *const Pane) bool { - return if (comptime pdf_enabled) - if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false - else - false; -} - -const Prompt = union(enum) { - none, - search: u16, - pipe: u16, - /// Save on a scratch buffer or a terminal: the tail is a path to write to. - save: u16, -}; - -pub const Pane = struct { - /// A pane's working directory. `.inherited` is a live `*Pane` link kept - /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup. - pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 }; - vt: term_pane.VtSlot, - stream: term_pane.StreamSlot, - /// The same allocator Pardes holds. A pane already owns heap (its content, - /// its emulator, its undo stacks) and Pardes frees all of it; this is here - /// so the pane methods that need the file's LINE INDEX — scrollBy and - /// ensureCursorVisible — can build it. The alternative was - /// threading an allocator through ensureCursorVisible's 33 call sites. - gpa: std.mem.Allocator, - /// WHICH pane this is, for anything that outlives the pane: slots are - /// REUSED (freeSlot hands back the lowest free one), so a remembered id - /// alone can silently come to mean an unrelated pane. Handed out by - /// Pardes.next_serial and never reused. The one reader is the jump stack. - serial: u32 = 0, - mode: Mode = .normal, - vweight: f32 = 1, - cols: u16, - rows: u16, - greet: bool = false, - pending_command: term_pane.PendingCommand = .{}, - file: ?file_pane.State = null, - image: ?image_pane.State = null, - pdf: PdfSlot = if (pdf_enabled) null else {}, - msel: LineSel = .{}, - vsel: CharSel = .{}, - /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary - /// index; pardes keeps the PRIMARY exactly where it has always been — - /// cur_row/cur_col + vsel — and the other ranges here, document-ordered - /// and disjoint (helix's Selection::normalize). That split is the whole - /// design: every motion, operator, renderer and mouse path in this file - /// still reads one selection, so with `nsel == 0` not a byte of behaviour - /// moves, and the 800 differential cases and 66 snapshots keep proving it. - /// The extra ranges are driven by replaying the single-selection key - /// handler once per range (see replaySels). - sels: [MAX_SELS - 1]SelRange = undefined, - nsel: u8 = 0, - /// helix select/extend mode (`v`): motions extend the selection from its - /// fixed anchor instead of replacing it. Reported as mode "select"; - /// pane.mode stays .normal (insert/tty transitions drop it). - select: bool = false, - /// sticky goal column for j/k runs (helix old_visual_position): any - /// non-vertical range write resets it to -1. - sticky_col: i32 = -1, - /// an `a` append session's original block-cursor cell: Esc backs the - /// cursor up one grapheme and rebuilds the appended-over selection from - /// here (helix doc.restore_cursor). Null outside `a` sessions. - append_at: ?struct { row: i32, col: i32 } = null, - /// Compact storage for normal_input.State's match sub-prefix. - pending2: u21 = 0, - /// Compact storage for normal_input.State's `mr` held char. - pending_ch: u21 = 0, - /// Compact storage for normal_input.State's count (0 = none). - count: u32 = 0, - /// last f/F/t/T motion, for Alt-. repeat - find_op: u8 = 0, - find_ch: u21 = 0, - /// Compact storage for normal_input.State's typed prefix. - pending: u21 = 0, - /// Tag-tail input state. The tag text is presentation; this tag carries - /// which operation owns it and the tail offset restored on submit/cancel. - prompt: Prompt = .none, - /// WHICH of helix's shell commands armed `prompt.pipe`. Beside the prompt - /// rather than inside it because `promptAt` reads all three prompt kinds - /// through one prong, and a payload here would have split that. - pipe_how: normal_input.PipeBehavior = .replace, - search_pane: ?usize = null, - search_row: ?usize = null, - /// Where n/N last stood in this pane, or null when the walk has not been - /// here. Distinct from `search_row`, which points into the RESULTS BUFFER - /// a search armed on this pane; this one is a place in the pane's own - /// text, and n/N step it in every kind of pane. - look_at: ?LookSpot = null, - /// The selection an `s`/`S` input was armed on, as gap offsets over the - /// motion surface. Every keystroke re-derives the preview FROM here rather - /// than from the previous preview — which is what helix's regex_prompt - /// does (it reverts to its snapshot before each update), what makes typing - /// a pattern one character at a time land on the same answer as pasting it - /// whole, and what makes Esc a plain restore with nothing else to undo. - /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one - /// place the prompt is cleared, clears it too. - sel_snap: [MAX_SELS]modal.HxRange = undefined, - nsel_snap: u8 = 0, - sel_snap_pri: u8 = 0, - /// ...including whether it was a user-intent selection: a preview IS one - /// (you picked those matches), but restoring must not silently promote - /// motion residue into something the acme chords will act on - sel_snap_expl: bool = false, - /// the editable tag tail: a bounded one-line command buffer. Input that - /// does not fit is refused atomically. - tag_tail: [TAG_TAIL_CAP]u8 = undefined, - tag_tail_len: usize = 0, - tag_init: bool = false, - tag_edit: bool = false, - tag_sel: bool = false, - /// the body mode a tag edit hijacked (tags are always insert); terminals - /// restore it on exit so clicking the tag never changes the pane's mode - tag_mode: Mode = .normal, - /// THE tag coordinate space: UTF-8 byte offsets into the WHOLE rendered - /// tag, prefix ++ tail (tagText), always on grapheme boundaries. Motions - /// and edits use these offsets; rendering and pointer input convert at the - /// screen boundary. The prefix is live chrome, so it is selectable, - /// yankable and executable but READ-ONLY: every edit op measures from - /// `edit0` (= tagPrefix().len, the first editable byte) and does nothing - /// left of it. - tag_col: u16 = 0, - tag_anchor: u16 = 0, - ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined, - ed_undo_len: usize = 0, - ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined, - ed_redo_len: usize = 0, - /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live - /// link to the pane it was opened from (.inherited), or unknown (.none). - /// The inherited pointer is kept valid by deferred pane teardown + fixup. - cwd: Cwd = .none, - cwd_buf: [limits.cwd_buf_cap]u8 = undefined, - /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, - /// or the terminal's shell rows with its edit buffer standing in). Tracks - /// the shell cursor until pinned by a click or a key. - cur_pinned: bool = false, - cur_row: i32 = 0, - cur_col: i32 = 0, - /// horizontal scroll, file panes only (terminals wrap at pty width, they - /// never have wider lines): content columns hidden left of the gutter. - /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, - /// the goal is just being able to read long lines. Byte columns, like the - /// rest of the file-pane code. - hscroll: i32 = 0, - /// THE WRAP MAP, and the whole of what soft line breaks are: for every body - /// row of the LAST frame, the document line it showed and the byte column - /// of that line the row started at. `wrap_n == 0` says the body was NOT - /// wrapped, i.e. the rows are the lines from `scroll()` down one each — - /// which is exactly what wrapAt/wrapRow below fall back to, so with the - /// toggle off not one reader computes anything it did not compute before. - /// - /// INVALIDATION, the part that rots if nobody says it out loud: written in - /// EXACTLY ONE PLACE, file_pane.bodyText, on every build of a file pane's - /// body. So it is at worst one frame old — which is what a mouse click - /// wants (you click the character you can SEE), and it is fresh for the - /// render passes, every one of which runs after bodyText inside the same - /// renderPane call. Nothing else may write it; a second writer is a second - /// truth, and the first click on a stale row is how you find out. - /// - /// ponytail: a fixed `limits.wrap_rows` rows (256; 128 on the board). A pane - /// taller than that does not wrap at all — bodyText leaves wrap_n at 0 and - /// clips the way it always did — rather than half-recording a mapping - /// every site here would then have to distrust. `wrapWidth` derives that - /// refusal from `wrap_line.len` itself, so the bound follows the array. - /// Grow the arrays the day a taller window turns up. - wrap_line: [limits.wrap_rows]i32 = undefined, - wrap_col: [limits.wrap_rows]i32 = undefined, - wrap_n: u16 = 0, - sel: [3]Sel = @splat(.{}), - /// terminals only: the typed-text buffer standing in for shell rows - ovl: ?term_pane.EditBuffer = null, - /// every raw pty byte, in order — a bounded dump/replay ring. Once full, - /// new output evicts the oldest bytes while the live terminal still sees - /// every byte. A megabyte, inline: see `ReplaySlot`. - tty_stream: ReplaySlot = if (terminal_panes) undefined else {}, - tty_stream_head: usize = 0, - tty_stream_len: usize = 0, - /// Terminal-only, pane-local presentation mode. Ghostty remains the owner - /// of the unmodified VT palette and dynamic OSC colours; the renderer - /// projects them through the active Pardes theme when this is set. - tty_filter: bool = false, - /// query replies ghostty computed (DSR, DA, kitty); the stream handler has - /// no path to the effect queue, so they land here and sync() drains them - /// into write effects. Bounded: replies are tiny escape sequences. - reply: ReplySlot = if (terminal_panes) undefined else {}, - reply_len: u16 = 0, - /// THE TRANSIENT MESSAGE: what just happened to this pane, drawn on its - /// LAST row until the next key or mouse event wipes it (see update). Fixed - /// and inline like `reply` above — a message is one short line, so a pane - /// that never sees one still costs nothing to carry it, and there is no - /// allocation to fail at the moment something is trying to be reported. - /// Written in exactly one place, Pardes.setMessage, by a SHELL. - msg: [256]u8 = undefined, - msg_len: u16 = 0, - - fn tagSlice(p: *const Pane) []const u8 { - return p.tag_tail[0..p.tag_tail_len]; - } - fn promptAt(p: *const Pane) ?u16 { - return switch (p.prompt) { - .none => null, - .search, .pipe, .save => |at| at, - }; - } - - fn hasSearchPrompt(p: *const Pane) bool { - return switch (p.prompt) { - .search => true, - else => false, - }; - } - - fn hasPipePrompt(p: *const Pane) bool { - return switch (p.prompt) { - .pipe => true, - else => false, - }; - } - - fn hasSavePrompt(p: *const Pane) bool { - return switch (p.prompt) { - .save => true, - else => false, - }; - } - - fn appendTag(p: *Pane, text: []const u8) bool { - if (text.len > p.tag_tail.len - p.tag_tail_len) return false; - @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); - p.tag_tail_len += text.len; - return true; - } - - fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { - if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; - std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); - p.tag_tail[at] = byte; - p.tag_tail_len += 1; - return true; - } - - fn removeTagByte(p: *Pane, at: usize) void { - if (at >= p.tag_tail_len) return; - std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); - p.tag_tail_len -= 1; - } - - pub fn cwdSlice(p: *const Pane) []const u8 { - return switch (p.cwd) { - .none => "", - .owned => |dir| dir, - .inherited => |src| src.cwdSlice(), - }; - } - - /// Shell-reported directory: own the bytes in cwd_buf. - pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void { - const n = @min(dir.len, pane.cwd_buf.len); - @memcpy(pane.cwd_buf[0..n], dir[0..n]); - pane.cwd = .{ .owned = pane.cwd_buf[0..n] }; - } - - pub fn isTerminal(pane: *const Pane) bool { - const no_pdf = if (comptime pdf_enabled) pane.pdf == null else true; - return pane.file == null and pane.image == null and no_pdf; - } - - /// The one coloring choice keyed on what a pane IS, so the highlight - /// producer (refreshHighlights) and the render pass agree on the algorithm. - pub const ColorAlgo = enum { none, tty, source, diff, locations }; - pub fn colorAlgo(pane: *const Pane) ColorAlgo { - if (pane.isTerminal()) return .tty; - if (pane.file) |f| { - if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; - // A RENDERING, not a document: +Grep, +Search, +Lsp and their kin - // have no language of their own, and their rows quote several at - // once. Colour each row by the file its location names instead. - // Buffers with no locations in them (+Help, +Config) match nothing - // and stay plain, so this needs no table of which origins qualify. - // - // `saves` is the line between the two: a New scratch and a real - // file are output-shaped but ARE documents, with one language and - // an edit on every keystroke — they keep `.source`, which is both - // right for them and what keeps a megabyte of scratch off the - // whole-buffer pass below. - if (f.output != null and !output_pane.fileTraits(f.output).saves) return .locations; - return .source; - } - return .none; - } - - pub fn pdfPath(pane: *const Pane) ?[]const u8 { - if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; - return null; - } - - pub fn pdfPage(pane: *const Pane) ?usize { - if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; - return null; - } - - /// Surface row of shell row `g`. The edit buffer's lines stand in for the - /// `rows` shell rows it covers, so everything below it slides by the - /// difference — the identity on files and on terminals nobody has typed - /// a newline into, which is why the rest of the row math can stay naive. - pub fn surfRow(pane: *const Pane, g: i32) i32 { - const o = pane.ovl orelse return g; - if (g <= o.row) return g; - const lines: i32 = @intCast(modal.lineCount(o.text)); - if (g >= o.row + o.rows) return g + lines - o.rows; - return @min(g, o.row + lines - 1); // inside the buffer: its own rows - } - - /// the inverse; every surface row inside the edit buffer maps to its anchor - pub fn gridRow(pane: *const Pane, s: i32) i32 { - const o = pane.ovl orelse return s; - if (s <= o.row) return s; - const lines: i32 = @intCast(modal.lineCount(o.text)); - if (s < o.row + lines) return o.row; - return s - lines + o.rows; - } - - /// current scroll offset: file top line, or the scrollback offset - 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(term_pane.gridOffset(pane)); - } - - /// The document position a BODY ROW begins at — `vr` 0 is the first row - /// under the tag. The screen->document half of the wrap map, and the half - /// the mouse asks: a click lands on the character the user can see, which - /// is last frame's arrangement, which is what the map holds. - pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { - if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) - return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; - // unwrapped: rows ARE lines, and the byte column a row starts at is the - // horizontal scroll (always 0 on a terminal, which never has one) - return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; - } - - /// ...and back: the body row `line`:`col` renders on, plus the byte column - /// that row starts at — subtract it from a document column to get a screen - /// one. `row` is -1 when the position is not on screen, which only a - /// wrapped body ever says: unwrapped the arithmetic answers for any line at - /// all and the callers' own bounds checks do the rejecting, as before. - pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { - if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; - var i: u16 = 0; - while (i < pane.wrap_n) : (i += 1) { - if (pane.wrap_line[i] != line) continue; - // the LAST row of a line owns every column past its start, so a - // cursor parked on the trailing newline still has somewhere to draw - if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; - return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; - } - return .{ .row = -1, .at = 0 }; - } - - /// ponytail: `delta` is LOGICAL LINES, wrapped or not — one `j` past the - /// bottom scrolls a whole line even when that line is five screen rows, and - /// a wheel tick or a Ctrl-d page counts lines rather than rows. So a body - /// full of long lines scrolls in jumps, and the view can never sit at the - /// MIDDLE of a wrapped line. That is the ceiling the whole feature buys its - /// smallness with: wrap is render + hit-test and nothing else in the editor - /// knows about it. The upgrade is to make f.scroll a (line, row-within-line) - /// pair, which every reader of it — this, the scrollbar, the syntax window, - /// bodyText, ensureCursorVisible, the gutter click — would then have to - /// learn; do that when scrolling long lines actually annoys someone. - fn scrollBy(pane: *Pane, delta: i32) void { - if (pane.file) |*f| { - const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); - const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); - const next: usize = @intCast(n); - if (next != f.scroll) { - f.scroll = next; - f.syntax_dirty = true; - } - } else if (hasPdf(pane)) { - if (comptime pdf_enabled) { - const pv = &pane.pdf.?; - const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); - pv.text_scroll = @intCast(std.math.clamp( - @as(i64, @intCast(pv.text_scroll)) + delta, - 0, - max, - )); - } - } else { - // the vt scrolls in SHELL rows; convert through the edit buffer - const off = term_pane.gridOffset(pane); - term_pane.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); - } - } - - pub fn ensureCursorVisible(pane: *Pane) void { - // scrolloff margin, shrunk on short panes so the band stays non-empty - var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - const off = pane.scroll(); - // A WRAPPED body shows fewer LINES than it has rows, and scrolling is - // still by line, so the bottom of the view is not off+rows-1 — a long - // line at the bottom would leave the cursor below the last row it can - // actually see. What the map is asked for is the COUNT of lines that - // fit, not which ones: this runs on every cursor move and the map is - // last FRAME's, but several keys can arrive between two renders (an - // autorepeated j, a paste) and then its absolute line numbers name a - // scroll offset that has already moved on — reading them cost a - // batched j four extra lines of scroll per keystroke. A count is - // scroll-independent, and when nothing wrapped it is exactly `rows`, - // so this whole block is a no-op on an unwrapped body and the margin - // clamp below reduces to the short-pane one above it. - // - // ponytail: last frame's line count applied to this frame's offset. It - // is exact whenever a render happened in between (the normal case) and - // an estimate mid-batch, self-correcting on the next key. The exact - // answer is to re-walk the lines from `off` accumulating wrapped - // heights — do that when a batch visibly lands the cursor off screen. - var last = off + @as(i32, pane.rows) - 1; - if (pane.wrap_n > 0) { - const lines_shown = pane.wrap_line[pane.wrap_n - 1] - pane.wrap_line[0]; - last = off + lines_shown; - margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); - } - if (pane.cur_row < off + margin) { - pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 - } else if (pane.cur_row > last - margin) { - // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); - // terminals overshoot harmlessly — the vt clamps at the live bottom - var to = pane.cur_row + margin; - if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); - if (comptime pdf_enabled) { - if (pane.pdf) |pv| - to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); - } - pane.scrollBy(@max(0, to - last)); - } - // the horizontal mirror, files only: keep scroll_off columns of - // context around the cursor (wheel-driven hscroll is exempt — it - // never moves the cursor, and a cursor move pulls the view back). - // A wrapped body has nothing to scroll sideways, and its hscroll is - // left ALONE rather than zeroed: turn the wrap back off and the view - // you had is still there. - if (pane.file) |f| { - if (pane.wrap_n != 0) return; - const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); - const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); - const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); - const raw_cur: usize = @intCast(@max(0, pane.cur_col)); - const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); - const cur = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_cur))); - const visual_scroll = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_scroll))); - var target = visual_scroll; - if (cur < visual_scroll + hmargin) - target = @max(0, cur - hmargin) - else if (cur > visual_scroll + w - 1 - hmargin) - target = cur - (w - 1 - hmargin); - if (target != visual_scroll) pane.hscroll = @intCast(file_pane.rawAtDisplay(line, @intCast(target))); - } - } - - fn pinCursor(pane: *Pane) void { - if (pane.cur_pinned) return; - if (pane.file != null or hasPdf(pane)) { - pane.cur_row = pane.scroll(); - pane.cur_col = 0; - } else { - const cur = term_pane.gridCursor(pane); - pane.cur_row = pane.surfRow(@as(i32, cur.y) + term_pane.gridOffset(pane)); - pane.cur_col = @intCast(cur.x); - } - pane.cur_pinned = true; - } -}; - const Drag = union(enum) { none, - /// `corner` is what makes this a CORNER grab: the press landed on a cell - /// that is both this v-border and one of the two adjoining columns' own - /// h-borders, and then the one drag moves both boundaries — cur_x the - /// column pair, cur_y `corner.col`'s pane pair at index `corner.idx`. - /// null is a plain edge drag and cur_y is only carried along for the - /// preview. The drag is a `border_v` on left_col either way; only the row - /// half changes which column it belongs to. - /// - /// Both adjoining columns count, left_col FIRST. The v handle IS left_col's - /// last cell, so left_col's horizontal hint is drawn straight THROUGH it - /// and its crossing reads as a full cross; the right column's spans start - /// one cell further right, so its crossing reads as a T butting into the - /// junction. Either way the two lines meet AT the handle cell, which is - /// what makes both grabbable. - /// - /// ponytail: a corner still moves exactly TWO boundaries, never three, so - /// when BOTH columns happen to be split at the grabbed row the LEFT one - /// wins and the right column's seam is left alone — the gesture that - /// existed before is bit-for-bit unchanged. border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, - /// a left sweep along a pane's TAG row: it drives the tag's own cursor and - /// selection (rendered-tag columns) rather than the body's block selection, - /// which is what makes a one-line tag select like a line of text tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert @@ -5032,13 +3920,9 @@ const Drag = union(enum) { ctrl: bool = false, /// This gesture began over a usable native PDF raster. The payload is /// zero-bit when PDF support is absent. - pdf: pdf_pane.PointerDrag = .{}, + pdf: panes.Pdf.PointerDrag = .{}, }, - /// The physical button whose release balances this gesture. Layout and - /// tag drags are all left-button gestures; a text selection remembers its - /// own acme button. Deriving this from the gesture keeps multi-button - /// chords exact without a parallel held-button mask. fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, @@ -5048,33 +3932,12 @@ const Drag = union(enum) { } }; -// The two border clamps, pulled out as plain arithmetic on plain numbers for -// one reason: a CORNER drag runs both of them off the same mouse position, and -// the thing that has to hold is that each one only ever looks at its own axis. -// A clamp that consulted the other axis — or a single "is this point legal" -// test over the pair — would freeze the whole gesture the moment either edge -// hit its stop, when what the hand expects is the free axis to keep tracking -// and the pinned one to sit at the wall. Being pure, they are also the piece -// worth a test; see below. - -/// Where a vertical border drag settles: `mcol` clamped so neither column of -/// the pair falls under MINW. `lx`/`lw` are the left column's x and width, -/// `rw` the right column's. Degenerate pairs (a window too narrow to hold two -/// minimums at all) pass the mouse through rather than snapping to a lie. fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 { const lo = lx + config.MINW; const hi = lx +| lw +| rw -| config.MINW; return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol; } -/// Where a horizontal border drag settles: `mrow` clamped so either pane may -/// shrink to just its tag row (BOX_H) but no further. `ay`/`ah` are the upper -/// pane's y and height, `bh` the lower pane's. -/// -/// The handle is the seam row that is a BODY row, which is the upper pane's -/// LAST row normally and — with Tagbottom, where that row is the upper pane's -/// tag — the lower pane's FIRST. That is the same seam one row further down, -/// so both walls simply move with it. fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 { const d: u16 = if (tag_bottom) 1 else 0; const lo = ay + BOX_H - 1 + d; @@ -5091,10 +3954,6 @@ test "a corner drag's two axes clamp independently" { const ay: u16 = TOPBAR_H; const ah: u16 = 15; const bh: u16 = 14; - // the walls, spelled out for tags-on-top (the tag_bottom = false below), - // where the handle is the upper pane's LAST row: the upper pane bottoms out - // with its tag row alone at ay, and the lower pane does the same one row - // above the pair's end const row_lo: u16 = ay + BOX_H - 1; const row_hi: u16 = ay + ah + bh - (BOX_H + 1); @@ -5115,9 +3974,6 @@ test "a corner drag's two axes clamp independently" { try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); - // Tagbottom moves the handle to the LOWER pane's first row, one further - // down, and both walls travel with it — either pane still bottoms out at - // its bare tag row and neither can be squeezed away try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true)); try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true)); @@ -5129,30 +3985,20 @@ test "a corner drag's two axes clamp independently" { try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false)); } -/// The screen row that is the handle between column `c`'s pane pair `k` and -/// `k+1`: the upper pane's LAST body row, or with Tagbottom — where that row is -/// the upper pane's tag — the lower pane's FIRST. The one place this rule -/// lives; the h hit test, the corner search and the hover hint all read it here. fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { - const r = p.rects[p.col_terms[c][k]]; + const r = p.rects[p.col_panes[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } -/// The corner fixture: the classic two-column boot with a SECOND pane added to -/// the RIGHT column, so both columns have a seam of their own and the v handle -/// between them can find either. fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 }); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); - p.active = p.col_terms[1][0]; + p.active = p.col_panes[1][0]; p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column p.sync(); return p; } -/// Moves column 1's own seam off column 0's, by the ordinary h-border gesture, -/// and answers the row it landed on. Both columns split at the SAME row is the -/// tie case, which is a different test. fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { const from = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; @@ -5175,8 +4021,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { try std.testing.expect(right_seam != seamRowOf(p, 0, 0)); const w0 = p.col_weight[0]; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expect(p.drag == .border_v); @@ -5191,8 +4037,8 @@ test "a v-handle press at the RIGHT column's seam drags both boundaries" { // both halves committed: the column pair widened, and the RIGHT column's // pane pair reweighted — while the left column's panes stayed put try std.testing.expect(p.col_weight[0] > w0); - try std.testing.expect(p.panes[p.col_terms[1][0]].?.vweight != v_right); - try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); + try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); } test "a tie row still moves the LEFT column's pane pair only" { @@ -5207,8 +4053,8 @@ test "a tie row still moves the LEFT column's pane pair only" { try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0)); const handle = p.col_x[0] + p.col_w[0] - 1; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); const corner = p.drag.border_v.corner orelse return error.NoCorner; @@ -5217,8 +4063,8 @@ test "a tie row still moves the LEFT column's pane pair only" { p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } }); p.sync(); - try std.testing.expect(p.panes[p.col_terms[0][0]].?.vweight != v_left); - try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); + try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a v-handle press at nobody's seam is still a plain edge drag" { @@ -5233,8 +4079,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { while (row == left_seam or row == right_seam) row += 1; const w0 = p.col_weight[0]; - const v_left = p.panes[p.col_terms[0][0]].?.vweight; - const v_right = p.panes[p.col_terms[1][0]].?.vweight; + const v_left = p.panes[p.col_panes[0][0]].?.vweight; + const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); try std.testing.expect(p.drag == .border_v); try std.testing.expect(p.drag.border_v.corner == null); @@ -5243,8 +4089,8 @@ test "a v-handle press at nobody's seam is still a plain edge drag" { p.sync(); // exactly ONE boundary moved try std.testing.expect(p.col_weight[0] > w0); - try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); - try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); + try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); + try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a RIGHT-column corner's two axes clamp independently" { @@ -5264,33 +4110,27 @@ test "a RIGHT-column corner's two axes clamp independently" { // and the mirror: below the bottom at mid-width. y parks, x tracks again p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } }); try std.testing.expectEqual(handle, p.drag.border_v.cur_x); - const a = p.rects[p.col_terms[1][0]]; - const b = p.rects[p.col_terms[1][1]]; + const a = p.rects[p.col_panes[1][0]]; + const b = p.rects[p.col_panes[1][1]]; try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); } test "a new column takes width only from the column that created it" { if (platform == .web) return; - // 124 deliberately makes the thirty-one-cell source split into unequal - // rounded halves. Independent width rounding moved the right column by a - // cell here; cumulative boundaries keep it pinned. const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); - // ids 2, 0, 1 now own 1/4, 1/4 and 1/2 of the screen. Splitting the - // middle column must consume its own thirty cells in place: the columns - // on both sides retain their exact rectangles, not merely their weights. const left_before = p.rects[2]; const right_before = p.rects[1]; _ = try p.newShell(3, ""); - try std.testing.expect(p.layoutSplitColumn(0, 3, false)); + try std.testing.expect(layout.splitColumn(p, 0, 3, false)); p.sync(); try std.testing.expectEqual(left_before.x, p.rects[2].x); @@ -5302,51 +4142,42 @@ test "a new column takes width only from the column that created it" { const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 }); defer narrow.deinit(); - try std.testing.expect(!narrow.layoutSplitColumn(0, 1, false)); + try std.testing.expect(!layout.splitColumn(narrow, 0, 1, false)); try std.testing.expectEqual(@as(usize, 1), narrow.ncol); - // Public callers may chain surgery before sync. The first split leaves - // two minimum-width columns, so the second must read the freshly-derived - // source width and refuse rather than consulting the old full-width rect. const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 }); defer sequential.deinit(); _ = try sequential.newShell(1, ""); - try std.testing.expect(sequential.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(sequential, 0, 1, false)); _ = try sequential.newShell(2, ""); - try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false)); + try std.testing.expect(!layout.splitColumn(sequential, 0, 2, false)); try std.testing.expectEqual(@as(usize, 2), sequential.ncol); - // Even a deliberately coarse restored proportion divides into two usable - // columns. Rebase preserves every ratio while giving an odd numerator an - // exact half instead of rendering weight 1:2 from the value 3. const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 }); defer coarse.deinit(); coarse.col_weight[0] = 3; _ = try coarse.newShell(1, ""); - try std.testing.expect(coarse.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(coarse, 0, 1, false)); coarse.sync(); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]); - // A failed rebase is transactional even when the source pane is being - // MOVED out of a stack. Previously absorb/remove ran before overflow was - // discovered, so false meant the pane had silently vanished. const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 }); defer extreme.deinit(); _ = try extreme.newShell(1, ""); - try std.testing.expect(extreme.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(extreme, 0, 1, false)); _ = try extreme.newShell(2, ""); - extreme.layoutInsert(0, 1, 2); + layout.insert(extreme, 0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; - extreme.computeGeom(); - const terms_before = extreme.col_terms; + layout.compute(extreme); + const panes_before = extreme.col_panes; const counts_before = extreme.col_n; const weights_before = extreme.col_weight; const source_vweight = extreme.panes[0].?.vweight; const sibling_vweight = extreme.panes[2].?.vweight; - try std.testing.expect(!extreme.layoutSplitColumn(0, 0, false)); - try std.testing.expectEqual(terms_before, extreme.col_terms); + try std.testing.expect(!layout.splitColumn(extreme, 0, 0, false)); + try std.testing.expectEqual(panes_before, extreme.col_panes); try std.testing.expectEqual(counts_before, extreme.col_n); try std.testing.expectEqual(weights_before, extreme.col_weight); try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight); @@ -5358,27 +4189,24 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 }); defer p.deinit(); - // Restore accepts each individual weight up to maxInt/6. Deleting three - // one-pane columns can legitimately coalesce four such values into an odd - // survivor above maxInt/2, which cannot be globally doubled for a split. for (1..4) |id| { _ = try p.newShell(id, ""); - try std.testing.expect(p.layoutSplitColumn(id - 1, id, false)); + try std.testing.expect(layout.splitColumn(p, id - 1, id, false)); } const restored_cap = std.math.maxInt(u64) / 6; for (0..4) |column| p.col_weight[column] = restored_cap; p.col_weight[3] -= 1; for ([_]usize{ 3, 2, 1 }) |id| { const pane = p.panes[id].?; - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.removePane(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2); try std.testing.expect(p.col_weight[0] % 2 == 1); - try std.testing.expect(!p.layoutCanSplitColumn(0)); + try std.testing.expect(!layout.canSplitColumn(p, 0)); const panes_before = p.panes; const serial_before = p.next_serial; try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); @@ -5392,22 +4220,22 @@ test "layout commits publish finite tracks only for changed panes" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(0, 2, true)); + try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); // Pane 1 belongs to the unrelated right column. Its exact layout stayed // fixed, so it does not receive a presentation record either. - try std.testing.expect(p.panel_tracks[1] == null); - try std.testing.expect(p.panel_tracks[0] != null); - try std.testing.expect(p.panel_tracks[2] != null); - try std.testing.expectEqual(panel_animation.Phase.moving, p.panel_tracks[0].?.phase); - try std.testing.expectEqual(panel_animation.Phase.opening, p.panel_tracks[2].?.phase); + try std.testing.expect(p.presentation.tracks[1] == null); + try std.testing.expect(p.presentation.tracks[0] != null); + try std.testing.expect(p.presentation.tracks[2] != null); + try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase); + try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase); try std.testing.expectEqual(untouched, p.rects[1]); try std.testing.expect(p.animationActive()); @@ -5416,7 +4244,7 @@ test "layout commits publish finite tracks only for changed panes" { const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); - for (0..panel_animation.Transition.slide.frames()) |_| p.update(.tick); + for (0..layout.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5424,13 +4252,32 @@ test "layout commits publish finite tracks only for changed panes" { p.settings.panel_transition = .zoom; p.update(.{ .resize = .{ .cols = 101, .rows = 17 } }); try std.testing.expect(!p.animationActive()); - for (p.panel_tracks) |track| try std.testing.expect(track == null); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); +} + +test "screen resize keeps its previous storage until allocation succeeds" { + var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .cols = 80, .rows = 24, .tty_only = true }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + const cells = surface.cells; + p.update(.{ .resize = .{ .cols = 81, .rows = 25 } }); + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.render(frame.allocator())); + try std.testing.expectEqual(cells.ptr, surface.cells.ptr); + try std.testing.expectEqual(cells.len, surface.cells.len); + try std.testing.expectEqual(@as(u16, 80), surface.cols); + try std.testing.expectEqual(@as(u16, 24), surface.rows); + failing.fail_index = std.math.maxInt(usize); + const resized = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u16, 81), resized.cols); + try std.testing.expectEqual(@as(u16, 25), resized.rows); + try std.testing.expectEqual(@as(usize, 81 * 25), resized.cells.len); } test "vertical close samples only a canonical baseline containing that pane" { - // Deleting an opener before its first canonical frame must not animate - // unrelated cells from the older global baseline as though they belonged - // to the short-lived pane. { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); @@ -5441,15 +4288,15 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); - p.removePane(1); + try p.removePane(1); p.sync(); - try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); } // Once the pane itself has reached a canonical acknowledged frame, that @@ -5464,19 +4311,19 @@ test "vertical close samples only a canonical baseline containing that pane" { p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick); + for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; - p.removePane(1); + try p.removePane(1); p.sync(); - try std.testing.expectEqual(@as(usize, 1), p.nclosing_panel_tracks); - try std.testing.expectEqual(serial, p.closing_panel_tracks[0].serial); + try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len); + try std.testing.expectEqual(serial, p.presentation.closing[0].serial); } } @@ -5490,7 +4337,7 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); @@ -5501,22 +4348,19 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. - p.panel_cell_diffs[changed_index] = .unchanged; + p.presentation.diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); - // A second opener before the first canonical endpoint has no truthful - // single old grid. Submit this layout canonically instead of rewinding to - // the boot baseline or manufacturing a stale closing pane. _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, false)); + try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); - try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready); - try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks); - for (p.panel_tracks) |track| try std.testing.expect(track == null); - try std.testing.expect(p.panel_presentation_pending); + try std.testing.expect(p.presentation.diff_state == .none); + try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + try std.testing.expect(p.presentation.pending); _ = frame.reset(.retain_capacity); const snapped = try p.render(frame.allocator()); @@ -5533,7 +4377,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const animated = try p.render(frame.allocator()); @@ -5543,7 +4387,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { // empty record set; producers cannot resume on its next frame. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(!p.animationActive()); - try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready); + try std.testing.expect(p.presentation.diff_state == .none); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -5557,15 +4401,12 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const moving = try p.render(frame.allocator()); p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); - p.applySettingBuiltin(runtime_cfg.find("PanelZoom").?, null); - try std.testing.expectEqual(panel_animation.Transition.zoom, p.settings.panel_transition); - try std.testing.expect(p.panel_presentation_pending); - try std.testing.expect(p.presentedPointer(10, 4) == null); - for (p.panel_tracks) |track| try std.testing.expect(track == null); - - // Once canonical has replaced that sample, make another real transition. - // Re-applying the effective tagline percentage is inert, but changing it - // invalidates the frozen raster's metrics and therefore snaps the tracks. + p.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); + try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); + p.acknowledgePanelPresentation(&.{}); p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; @@ -5577,14 +4418,14 @@ test "canonical fallback and transition toggle retire unpresented tracks" { const old_tagline_percent = p.settings.font.tagline_percent; var percent_buf: [3]u8 = undefined; const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent}); - p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, same_percent); + p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); - p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, changed_text); + p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); - try std.testing.expect(p.panel_presentation_pending); - for (p.panel_tracks) |track| try std.testing.expect(track == null); + try std.testing.expect(p.presentation.pending); + for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "pointer coordinates follow presented panel geometry" { @@ -5592,7 +4433,7 @@ test "pointer coordinates follow presented panel geometry" { defer p.deinit(); const pane = p.panes[0].?; - const moving: panel_animation.Track = .{ + const moving: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5603,13 +4444,13 @@ test "pointer coordinates follow presented panel geometry" { p.acknowledgePanelPresentation(&.{moving}); // One shared physical-to-logical map feeds all pointer gestures. The // selected sample is 26.25% across and 35% down the presented rectangle. - try std.testing.expectEqual(Pardes.PointerCell{ .col = 21, .row = 7 }, p.presentedPointer(30, 8).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. - try std.testing.expect(p.presentedPointer(2, 2) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. - try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); - p.acknowledgePanelPresentation(&.{panel_animation.Track{ + p.acknowledgePanelPresentation(&.{layout.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5617,11 +4458,11 @@ test "pointer coordinates follow presented panel geometry" { .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }}); - try std.testing.expect(p.presentedPointer(40, 10) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null); // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. - const overlap = [_]panel_animation.Track{ .{ + const overlap = [_]layout.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5636,13 +4477,10 @@ test "pointer coordinates follow presented panel geometry" { .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 }, } }; - // Slot one is deliberately absent from the fixture, so install this - // synthetic overlap directly; the production acknowledgement rejects - // dead pane lifetimes before they can participate in input. - p.presented_panel_tracks = @splat(null); - p.presented_panel_tracks[0] = overlap[0]; - p.presented_panel_tracks[1] = overlap[1]; - try std.testing.expectEqual(Pardes.PointerCell{ .col = 65, .row = 3 }, p.presentedPointer(25, 3).?); + p.presentation.shown_tracks = @splat(null); + p.presentation.shown_tracks[0] = overlap[0]; + p.presentation.shown_tracks[1] = overlap[1]; + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { @@ -5653,42 +4491,42 @@ test "queued pointer input is inert until a changed layout is presented" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - try std.testing.expect(p.panel_presentation_pending); - try std.testing.expect(p.presentedPointer(10, 4) == null); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); - var tracks: [MAX_PANES]panel_animation.Track = undefined; + var tracks: [MAX_PANES]layout.Track = undefined; var len: usize = 0; - for (p.panel_tracks) |maybe| if (maybe) |track| { + for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); - try std.testing.expect(!p.panel_presentation_pending); + try std.testing.expect(!p.presentation.pending); // Whether this particular opening sample exposes the chosen cell is the // transition's concern; it is no longer rejected merely as speculative. - try std.testing.expect(p.presentedPointer(99, 4) != null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null); } test "back-to-back layout commits retarget from the last presented boxes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); - const shown_a = Pardes.panelBox(p.rects[0]); + const shown_a = layout.panelBox(p.rects[0]); p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); - const target_b = p.panel_tracks[0] orelse return error.MissingFirstRetarget; + const target_b = p.presentation.tracks[0] orelse return error.MissingFirstRetarget; try std.testing.expect(target_b.from.eql(shown_a)); // No render or acknowledgement of B: the pixels are still A. C must not @@ -5696,21 +4534,21 @@ test "back-to-back layout commits retarget from the last presented boxes" { p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); - const target_c = p.panel_tracks[0] orelse return error.MissingSecondRetarget; + const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); - try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0]))); - try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase); + try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0]))); + try std.testing.expectEqual(layout.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); - var submitted: ?panel_animation.Track = null; + var submitted: ?layout.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); - try std.testing.expect(p.presented_panel_layout[0].?.box.eql(track.visualBox())); + try std.testing.expect(p.presentation.shown[0].?.box.eql(track.visualBox())); } test "an unpresented opening pane remains in the opening paint phase" { @@ -5723,16 +4561,16 @@ test "an unpresented opening pane remains in the opening paint phase" { p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - const first = p.panel_tracks[1] orelse return error.MissingOpeningTrack; - try std.testing.expectEqual(panel_animation.Phase.opening, first.phase); + const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack; + try std.testing.expectEqual(layout.Phase.opening, first.phase); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, true)); + try std.testing.expect(layout.splitColumn(p, 1, 2, true)); p.sync(); - const retargeted = p.panel_tracks[1] orelse return error.MissingOpeningRetarget; - try std.testing.expectEqual(panel_animation.Phase.opening, retargeted.phase); + const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; + try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } @@ -5805,9 +4643,6 @@ test "core composes character motion out of the new grid, not a fade" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - // Row zero is sliding in from the left screen edge: it holds real glyphs - // from two columns further right, and the cells its text has not reached - // yet keep the frozen old grid rather than a blend or a blank. const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); try std.testing.expect(presented != &surface); var seen: [8]u8 = undefined; @@ -5816,7 +4651,7 @@ test "core composes character motion out of the new grid, not a fade" { try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); // The last active sample is the untouched canonical grid, with no copy. - surface.panel_tracks[0].frame = panel_animation.Transition.edges.frames() - 1; + surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1; try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } @@ -5824,24 +4659,24 @@ test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - const target: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; - p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_cell_diffs, .unchanged); + const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { // Distances on both sides of frame five prove that finished ASCII // bytes are clickable while bytes still walking are not. - p.panel_cell_diffs[row * p.screen_w + col] = .{ .ascii = .{ + p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } - p.panel_diff_ready = true; + p.presentation.diff_state = .ready; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; - const track: panel_animation.Track = .{ + const track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -5860,22 +4695,22 @@ test "pointer rejects panel content cells which have not materialized" { const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { - .dissolve => panel_animation.dissolveRevealed( + .dissolve => layout.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), - .ascii => switch (p.panelCellDiff(col, row)) { + .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; - const mapped = p.presentedPointer(col, row); + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| - try std.testing.expectEqual(Pardes.PointerCell{ .col = col, .row = row }, point); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; @@ -5888,12 +4723,12 @@ test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - p.panel_cell_diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); - @memset(p.panel_cell_diffs, .unchanged); - p.panel_diff_ready = true; - const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; + p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); + @memset(p.presentation.diffs, .unchanged); + p.presentation.diff_state = .ready; + const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; - for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, @@ -5902,7 +4737,7 @@ test "unchanged content cells remain clickable through data effects" { .from = box, .to = box, }}); - try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(8, 3).?); + try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } @@ -5912,13 +4747,13 @@ test "stationary Look hover follows only acknowledged panel samples" { defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; - const target: panel_animation.Box = .{ + const target: layout.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; - var track: panel_animation.Track = .{ + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -5933,7 +4768,7 @@ test "stationary Look hover follows only acknowledged panel samples" { // Advancing the producer-side track alone cannot move the semantic cell // under a stationary pointer. track.frame = track.effect.frames() - 1; - p.panel_tracks[0] = track; + p.presentation.tracks[0] = track; p.refreshLookHoverFromRaw(); try std.testing.expectEqual(first, p.look_hover_wait.?); @@ -5963,9 +4798,6 @@ test "stationary Look hover follows only acknowledged panel samples" { try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); - // The motion intent itself survives temporary invisibility. When a later - // successfully presented sample materializes under the stationary raw - // pointer, it can begin a fresh delay without synthetic mouse motion. track.frame = track.effect.frames() - 1; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait != null); @@ -5975,8 +4807,8 @@ test "held drag follows acknowledged panels and balances an invisible release" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - const target = Pardes.panelBox(p.rects[0]); - var track: panel_animation.Track = .{ + const target = layout.panelBox(p.rects[0]); + var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -6000,7 +4832,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { // Advancing producer state alone cannot move input. Only the sample the // host says it actually drew may remap the stationary held endpoint. track.frame = track.effect.frames() - 1; - p.panel_tracks[0] = track; + p.presentation.tracks[0] = track; try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1); p.acknowledgePanelPresentation(&.{track}); @@ -6017,7 +4849,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); - try std.testing.expect(p.presentedPointer(raw_col, raw_row) == null); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null); try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1); @@ -6046,12 +4878,9 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { defer p.deinit(); _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - // Make the pair 418/561 through the real divider path. This ratio was a - // counterexample for f32 weights: splitting the right side twice changed - // the already-created middle boundary by one cell. const handle = p.col_x[0] + p.col_w[0] - 1; const untouched = p.col_weight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); @@ -6065,101 +4894,43 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { try std.testing.expectEqual(@as(u16, 418), p.rects[0].w); _ = try p.newShell(2, ""); - try std.testing.expect(p.layoutSplitColumn(1, 2, false)); + try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); const left_before = p.rects[0]; const middle_before = p.rects[1]; _ = try p.newShell(3, ""); - try std.testing.expect(p.layoutSplitColumn(2, 3, false)); + try std.testing.expect(layout.splitColumn(p, 2, 3, false)); p.sync(); try std.testing.expectEqual(left_before, p.rects[0]); try std.testing.expectEqual(middle_before, p.rects[1]); } -pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; - -const LayoutSnapshot = struct { - serial: u32, - box: panel_animation.Box, -}; +pub const Rect = layout.Rect; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, - /// argv FILE (resolved absolute): boot with it as the ONLY pane, focused - /// and filling the window. It names the whole boot layout, so `shells` is - /// not consulted at all (it never was — the file arm always won). file: ?[]const u8 = null, file_line: usize = 0, - /// argv NAMED SOMETHING THAT IS NOT THERE — no file, no directory, nothing - /// `look.resolve` could make a target of. The word as the human typed it - /// and the directory they typed it in, and it boots one `+Errors` pane - /// saying so. - /// - /// A launch is not a failure worth refusing. `pardes nosuchfile` used to - /// return `BadArgs` out of `main`, which std prints as `error: BadArgs` - /// with a return trace under it — indistinguishable from a crash for a - /// typo, and it left the human with no editor at all. Outranked by `file` - /// for the same reason `file` outranks `shells`: only one of them can name - /// the boot layout, and they are never both set. - /// - /// `dir` is the LAUNCH DIRECTORY, and the pane needs it for the same - /// reasons every other pane needs one: it is where a `Grep` from that pane - /// walks, where its `Newtty` spawns a shell, and what its `Save` prefills. missing: ?struct { word: []const u8, dir: []const u8 } = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, - /// `--nested`: run a full session even inside another pardes. The core - /// never reads it; it rides here because it is the shells that would - /// otherwise open the nested.zig socket, and this is the way argv already - /// reaches them. nested: bool = false, - /// Native main fills this with the contents of the per-user config init. - /// Keeping discovery out of the core makes constructors and web builds - /// deterministic; when present, each line is dispatched as a builtin - /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, - /// SERVE ACME'S CONTROL FILESYSTEM for this session (`--fs`), and where. - /// `null` is off; `""` means "derive the mount point" (a per-session - /// directory under `$XDG_RUNTIME_DIR`); anything else is the directory - /// `--fs=` named, which scripts and the snapshot harness need because - /// they have to predict it. - /// - /// One field rather than a flag plus a path: two of those encode a state - /// ("no filesystem, mounted here") that means nothing. The core never - /// mounts anything — a mount is a host's business, and one host (the - /// browser) has no filesystem at all — but the option rides here because - /// argv already reaches the shells this way, like `nested`. - fs: ?[]const u8 = null, - /// SERVE THAT SAME TREE OVER 9P2000 on a unix socket (`--fs9`), and under - /// what name. `null` is off; `""` means "derive the socket name" (the - /// session name in a daemon, this pid anywhere else); anything else is the - /// name `--fs9=` gave, which scripts need because they have to - /// predict `$XDG_RUNTIME_DIR/pardes-9p-.sock`. - /// - /// INDEPENDENT of `fs` above: either, both or neither. They are two - /// transports onto one tree (`src/acmefs.zig`) and neither is the other's - /// prerequisite — on Linux the FUSE mount needs `fusermount3` and a - /// kernel with FUSE, and this needs neither, which is the whole reason - /// docs/9p.typ §12.4 calls them complementary rather than competing. - /// - /// Read by `detached/server.zig` and nowhere else so far: a daemon polls - /// its own listener in the one `poll(2)` it already runs, which costs it - /// no thread. The tty and GUI shells would each need their own wake for - /// it, exactly as they need one for `/dev/fuse`, and they take `fs` only. - fs9: ?[]const u8 = null, - /// Native launcher's resolved per-user `pardes` directory. Relative - /// ThemeFile operands and DumpThemes are rooted here. Null for web and - /// direct core callers which did not opt into per-user configuration. + ninep_name: []const u8 = "", + ninep_tcp: ?[]const u8 = null, + ninep_quic: ?[]const u8 = null, + ninep_identity: struct { + socket_path: []const u8 = "", + tcp_address: ?std.Io.net.IpAddress = null, + quic_address: ?std.Io.net.IpAddress = null, + } = .{}, + mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, - /// ...and WHERE that came from, which is a separate fact: the path - /// resolves even when the file does not exist, and that is precisely the - /// case the Config builtin is asked about. Null on the web and in every - /// core test, where there is no per-user config to name. startup_config_path: ?[]const u8 = null, image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, @@ -6167,20 +4938,13 @@ pub const Options = struct { /// Where each frame's Surface text is built. Hosts pass a purpose-built /// stack-fallback arena; null means the general allocator. frame_allocator: ?std.mem.Allocator = null, - /// Initial grid. Shell contract: for LIVE sessions leave these at the - /// defaults and deliver the real size as the first resize EVENT — the core - /// defers an integrated shell's greeting until after a resize AND its OSC - /// 133 B input mark (so `ls` cannot race startup and wraps to the real pane - /// width); pre-sizing here means that resize never fires and the greeting - /// never runs. Pre-size only for dump loads (nothing greets, and it avoids - /// reflowing replayed content twice). cols: u16 = 80, rows: u16 = 24, }; /// The tag-tail marker each behaviour arms with. One function so the prompt /// that is DRAWN and the command that is PARSED can never disagree. -fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { +fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { return switch (how) { .replace => config.pipe_marker, .ignore => config.pipe_marker_to, @@ -6189,9 +4953,6 @@ fn pipeMarker(how: normal_input.PipeBehavior) []const u8 { }; } -/// One line that was said on a message row. Fixed storage so the log cannot -/// fail: `setMessage` is reached from `reportError`, which is reached from -/// paths that are reporting an allocation failure. pub const LoggedMessage = struct { pub const cap = 256; text: [cap]u8 = undefined, @@ -6215,15 +4976,10 @@ const PendingPipe = struct { command: []u8, cwd: []u8, inputs: []selection_pipe.Input, - ranges: [MAX_SELS]modal.HxRange, + ranges: [Pane.max_selections]modal.Selection, primary: u8, explicit: bool, - how: normal_input.PipeBehavior, - /// How many ranges the selection had. Not always `inputs.len`: `!` and - /// `A-!` send NO stdin and run the command ONCE, so they submit a single - /// empty input and paste that one answer at every range — helix's - /// `shell_output` cache, which is why `date` at ten cursors gives ten - /// identical stamps rather than ten different ones. + how: modal.Normal.PipeBehavior, nranges: u8, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { @@ -6244,29 +5000,18 @@ const PendingPipe = struct { } }; -/// The acme verb the core just performed, for a shell that can answer with -/// something physical. macOS taps the trackpad under the finger that asked -/// (NSHapticFeedbackManager); the SDL shell already does the same thing with a -/// gamepad — `rumble` in src/gui/deck.zig, "a brief gentle ack for -/// execute/look, not a buzz". Two verbs rather than one flag because they -/// deserve to feel different: Exec did something, Look went somewhere. pub const Haptic = enum { none, exec, look }; /// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; -/// Deferred pane teardown. A dropped pane's memory outlives the frame it died -/// in: it is doomed here and actually torn down one full frame later, so any -/// `*Pane` captured that frame — an effect, another pane's inherited cwd — -/// stays valid long enough for the per-frame fixup pass to repair it. `fresh` -/// holds this frame's drops, `stale` the previous frame's, freed next reap. -pub const PaneAllocator = struct { - fresh: [2 * MAX_PANES]?*Pane = @splat(null), - stale: [2 * MAX_PANES]?*Pane = @splat(null), - - fn doom(a: *PaneAllocator, pane: *Pane) void { - for (&a.fresh) |*slot| if (slot.* == null) { +pub const RetiredPanes = struct { + current: [2 * MAX_PANES]?*Pane = @splat(null), + previous: [2 * MAX_PANES]?*Pane = @splat(null), + + fn retire(a: *RetiredPanes, pane: *Pane) void { + for (&a.current) |*slot| if (slot.* == null) { slot.* = pane; return; }; @@ -6274,12 +5019,6 @@ pub const PaneAllocator = struct { // 2*MAX_PANES cannot fill in a single frame. unreachable; } - - fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool { - for (a.fresh) |s| if (s == pane) return true; - for (a.stale) |s| if (s == pane) return true; - return false; - } }; pub const Pardes = struct { @@ -6294,193 +5033,59 @@ pub const Pardes = struct { resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), - /// Deferred teardown of dropped panes (see PaneAllocator). - pane_alloc: PaneAllocator = .{}, + reserved_slots: [MAX_PANES]bool = @splat(false), + /// Deferred teardown of dropped panes (see RetiredPanes). + retired_panes: RetiredPanes = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, - /// Fixed-point horizontal proportions. Integer sums make splitting W - /// into A+B exactly associative, so an unrelated boundary cannot move - /// through floating-point regrouping after repeated source-local splits. col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), - col_terms: [MAX_COLS][MAX_PANES]usize = undefined, + col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), // derived each sync - rects: [MAX_PANES]Rect = undefined, + rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, - /// Last committed layout and the finite visual tracks derived from it. - /// Both are indexed by pane slot; serial rejects slot reuse. - layout_snapshot: [MAX_PANES]?LayoutSnapshot = @splat(null), - layout_snapshot_ready: bool = false, - /// Boot/config replay is not a visible layout event, and a host resize is - /// already continuous physical motion. Those paths snap this presentation - /// cache once instead of manufacturing panel transitions. - panel_animation_enabled: bool = false, - snap_panel_layout_once: bool = false, - /// Tracks the core is preparing for the next frame, and the independent - /// tracks a backend has actually put on screen. Input must follow the - /// latter: a failed GPU submit or a delayed AppKit draw cannot make an - /// unpresented animation tick clickable. - panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), - presented_panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null), - /// Deleted pane lifetimes cannot stay in the slot-indexed live table: a - /// slot may be reused while its old pixels are still leaving. Tombstones - /// are dense plain records and never retain a functional Pane. - closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, - nclosing_panel_tracks: usize = 0, - presented_closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined, - npresented_closing_panel_tracks: usize = 0, - /// Last canonical grid a backend explicitly acknowledged, and the frozen - /// semantic diff from it to the first canonical frame of this transition. - /// These buffers are capacity-reused and own no pane resources. - presented_cells: []Cell = &.{}, - presented_cells_cols: u16 = 0, - presented_cells_rows: u16 = 0, - presented_cells_valid: bool = false, - /// Pane lifetimes and boxes represented by `presented_cells`. This is - /// deliberately separate from `presented_panel_layout`, which follows - /// partially animated pixels. A closing tombstone may sample the frozen - /// grid only when that grid actually contains the exact pane box. - presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - panel_cell_diffs: []PanelCellDiff = &.{}, - panel_diff_pending: bool = false, - panel_diff_ready: bool = false, - /// Canonical pane lifetimes/boxes captured by render, and the visual boxes - /// from the last successful acknowledgement. Retargeting reads the latter, - /// never a logical layout which may not have reached the screen yet. - submitted_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - submitted_panel_layout_ready: bool = false, - presented_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null), - /// Becomes true on the first host acknowledgement. Sans-host unit callers - /// retain direct canonical pointer semantics; a real frontend thereafter - /// gets the strict unpresented interval below. - panel_presentation_ready: bool = false, - /// A layout mutation has not reached a backend yet. Pointer gestures are - /// inert during this normally sub-frame interval; guessing frame-zero - /// geometry would make queued input address pixels nobody has presented. - panel_presentation_pending: bool = false, + presentation: layout.Presentation = .{}, active: usize = 0, - /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` - /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and - /// the entries past it are the ones Ctrl-i walks forward into). Maintained - /// in exactly one place — sync() — and read by everything that asks "where - /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane - /// closes, Last, and the directory order a look resolves in. - /// - /// One list, not two. A jumplist kept beside a focus history is two things - /// to keep agreeing, and they would disagree the first time one of them - /// forgot a pane the other still names. jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, - /// THE PANES THAT HAVE LOOKED, oldest first, at most one entry each — the - /// spine n/N walks (lookWalkPanes). Serials rather than slots, for the - /// same reason the jumplist stores them: a freed slot is reused, and an - /// entry naming a dead pane must not resolve to the newcomer sitting in - /// its place. - /// - /// Not the jumplist, though it looks like one. `jumps` records where FOCUS - /// has been, and a look moves focus to what it OPENED; this records where - /// the look was made FROM, which is the pane holding the list you are - /// working through. The two answer different questions and would only - /// coincide by accident. look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, - /// Serial of the pane whose stream n/N is actively walking. Focus may - /// leave it when Enter/Look opens a row; provenance does not. Null/stale - /// falls back to the ordinary history order. look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, - /// Every user-settable, queryable display/runtime choice in one plain - /// authoritative record. Generated setting builtins mutate it directly; - /// rendering and the Config report read those same fields. - settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, - /// One theme-derived Ghostty palette shared by all filtered terminals. - /// Its value key makes a same-name ThemeFile reload invalidate it without - /// coupling terminal rendering to the theme-selection call sites. - tty_filter_palette: term_pane.FilterPalette = .{}, - /// Delivery bookkeeping, not configuration: prevents a synchronous host - /// pump from taking one still-pending font request more than once before it - /// acknowledges success or rejection. + settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, + tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, - /// One user theme loaded from a .zon file. The parsed value owns its name; - /// all color fields are inline. `theme_file_generation` makes a queued IO - /// request stale as soon as another ThemeFile/Theme/NextColor command wins. custom_theme: ?Theme = null, - custom_theme_active: bool = false, - /// Sized by `limits.host_path_cap`, which is 0 where the platform has - /// no filesystem to hold a theme file: `set` then refuses every non-empty - /// path and `themeFileRequest` answers `PathTooLong`, which is the honest - /// answer on a board whose only IO is a UART. - theme_file_path: runtime_cfg.Text(limits.host_path_cap) = .{}, + theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), - /// False only while startup configuration or a dump restore is selecting - /// its first theme. No frame is rendered in that interval. - /// - /// Nothing to do with `-Dtheme-animation`: that is decided by the type of - /// `chrome_animation`, so a build without the fade does not carry a flag - /// saying so. animate_theme_changes: bool = false, - /// Native pixel attachments supported by the shell (Kitty graphics in a - /// terminal, GPU textures in SDL). Image panes dynamically fall back to - /// the PETSCII matcher without it. native_images: bool = false, quit: bool = false, - /// The Look or Exec that has happened and not yet been felt, taken by the - /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs - /// inside one keystroke are still one thing the hand did. haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, - /// True only after buttonless motion. Presentation acknowledgements may - /// remap that stationary hover; keyboard/button/wheel input clears the - /// intent so an ordinary repaint cannot silently re-arm a cancelled hint. raw_hover_intent: bool = false, - /// Hosts explicitly tell us when the pointer leaves. Keeping this bit - /// separate from the last coordinates lets a drag retain its endpoint - /// while idle resize-handle hints disappear immediately outside a window. pointer_inside: bool = false, look_hover_wait: ?LookHoverWait = null, look_hover_preview: ?LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {}, - /// touchpad drift guard, counted down in horizontal wheel ticks — see - /// config.wheelTick, which owns the whole rule. Global, and clock-free on - /// purpose: the core has no clock, so "recently scrolled vertically" can - /// only mean "in the last few wheel events", which is all the heuristic - /// needs. - /// ponytail: it therefore never times out — only a horizontal tick spends - /// it, so a sideways swipe an hour after a scroll still pays the toll. If - /// that ever bites, clear it on any non-wheel event: a keypress or a click - /// is proof the gesture ended, and still needs no clock. wheel_guard: u8 = 0, ctrl_w_pending: bool = false, - /// A key is being REPLAYED over several selections (replaySels). The one - /// thing the replay cannot do is let a per-pass action that is really a - /// per-KEYSTROKE action fire once per range: the undo snapshot must be - /// taken once, the yank register accumulates instead of being overwritten, - /// and anything that opens, closes or focuses a pane (runBuiltin) or asks - /// the language backend (lspRequest) happens on the first pass and stops - /// the replay dead — see multiOnce. multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, - /// SPC leader in flight, holding the key path typed so far (empty = just - /// SPC). Global like ctrl_w_pending — there is one leader and it acts on - /// the active pane, whose transient message row shows the pending path. leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, - /// the TOPBAR holds the keyboard, parked at this UTF-8 byte offset of the - /// row-0 line. Global like leader_on for the same reason: row 0 is not a - /// pane and never will be, so its one piece of focus state cannot live on - /// one. `null` = the panes have the keyboard, which is every other frame. topbar_col: ?u16 = null, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, @@ -6490,18 +5095,6 @@ pub const Pardes = struct { /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, - /// The one language query in flight. ONE, deliberately: every one of these - /// is a keystroke the user is waiting on, so a second press means "I meant - /// this one" — the id bump makes the older answer stale and lspResponse - /// drops it. A queue would only buy the right to render an answer nobody - /// is waiting for any more. - /// `arg` holds the replacement name for rename and the query for workspace - /// symbols. `serial` rejects a response after its pane slot was reused; - /// `revision` makes a mutating rename conditional on the source snapshot - /// the worker actually analysed. `row`/`col` are where the cursor was when - /// the question was asked. Only `completion` reads them, and only to undo - /// itself: Tab diverted instead of indenting, so an empty answer has to put - /// the indent back — but only if the cursor has not moved since. lsp_seq: u32 = 0, lsp_wait: ?struct { id: u32, @@ -6514,32 +5107,17 @@ pub const Pardes = struct { col: i32 = 0, } = null, - /// One current shell-filter request. A newer submit frees and supersedes - /// it; old worker answers then fail the id check. The request itself owns - /// every byte a shell snapshots while draining the id-only effect. pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, - /// acme's control filesystem, when a host serves one (`pardes --fs`). - /// Zero-initialised and inert: a core nobody scripts pays for one branch - /// per edit and nothing else. See src/acmefs.zig. - fs: acmefs.State = .{}, + fs: filesystem.Namespace = .{}, - /// Pending effects, drained by the shell after each update. The bounded - /// ring preserves byte order; once full, later effects are refused so no - /// already-queued write can be reordered or silently evicted. + // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, effects_head: usize = 0, effects_len: usize = 0, - /// Pty bytes that did not fit the ring, per pane, kept because refusing a - /// `write` TRUNCATES a byte stream rather than merely delaying it: a 1 MiB - /// paste used to reach a program as its first 256 KiB, silently. `emitWrite` - /// parks the tail here and `nextEffect` refills the ring from it as the host - /// drains, so the stream is delayed and never cut. See `limits.pending_write_cap`. - /// - /// Per pane because two ptys are independent streams: only order WITHIN one - /// matters, so a pane whose tail is waiting never delays another's writes. + // Each pty retains its own overflow tail until earlier effects drain. pending_write: [MAX_PANES]?PendingWrite = @splat(null), /// Total bytes parked above, so the drain path costs one comparison when /// nothing is waiting — which is every frame that is not a large paste. @@ -6550,27 +5128,18 @@ pub const Pardes = struct { host: Host = .{}, /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. - fallback: Fallback, + fallback: host_io.Fallback, /// The message-row log: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. messages: [limits.message_log]LoggedMessage = @splat(.{}), /// Next slot to write. `messages_len` saturates at the ring's size. messages_head: usize = 0, messages_len: usize = 0, - /// Input the loop has not consumed yet. Single-threaded: a host's worker - /// threads keep their own thread-safe inbox and post from the loop thread, - /// which is what keeps this ring lock-free. in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, - /// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and - /// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the - /// two commands that cross that line. yank: ?[]u8 = null, - /// a `SPC p`/`SPC P`/`SPC R` waiting on the shell's clipboard read, or - /// null. At most one: a second request replaces the first, and any - /// keystroke abandons it (update). clip_pending: ?ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -6581,12 +5150,6 @@ pub const Pardes = struct { /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, - /// An Attach builtin wants this frontend's screen handed to a detached - /// core; the shell consumes it via takeAttach from its OUTER loop, beside - /// takeRestore and for the same reason — both END this core, and nothing - /// running inside `pump` may destroy the core it is running in. `name` - /// points into `attach_buf`, which the effect's inline copy is unpacked - /// into: the effect is a value the drain loop owns and dies with it. attach_req: ?AttachRequest = null, attach_buf: [attach_name_max]u8 = undefined, @@ -6597,8 +5160,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 term_pane.RowsCache for the lifetime rule - shell_rows: term_pane.RowsCache = .{}, + /// for — see panes.Terminal.RowsCache for the lifetime rule + shell_rows: panes.Terminal.RowsCache = .{}, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; @@ -6611,6 +5174,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -6618,36 +5186,20 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; if (opts.file) |path| { - // FILE argv boot: the doc alone, filling the window. Naming a file - // is asking to READ it, not to be handed a shell you did not ask - // for and have to close — and the launch directory is one Newcol - // away when it is wanted. Doc, PDF and image all boot the same way. const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) - break :initial_doc pdf_pane.openPane(p, 0, path, opts.file_line); + break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); if (look.isImagePath(path)) - break :initial_doc image_pane.create(p, 0, path, &.{}); - break :initial_doc file_pane.open(p, 0, path, opts.file_line); + break :initial_doc panes.Image.create(p, 0, path, &.{}); + break :initial_doc panes.File.open(p, 0, path, opts.file_line); }; - // ...AND IF IT WILL NOT OPEN, SAY SO IN THE WINDOW. `pardes /root` - // is a directory that resolves and cannot be read, so it arrives - // here as a `.file` and used to take the whole launch down with - // `error: PermissionDenied` and a return trace out of `main` — a - // crash, to the human, for asking to read something they are not - // allowed to read. Every environment reason lands in the same - // `+Errors` pane a missing name does, because they are the same - // event to whoever typed it: pardes cannot show you that. - // - // OUT OF MEMORY IS NOT ONE OF THEM and goes back to the caller. - // A core that could not allocate a file cannot allocate the pane - // explaining it, and pretending otherwise turns a clean failure - // into a second one. Every opener does its fallible IO BEFORE it - // claims a pane slot (`look.readFile`, then `newDocPane`), so slot - // 0 is still free here — which is what makes this legal. if (opened) |_| {} else |err| { if (err == error.OutOfMemory) return err; - const why = switch (err) { + const why = switch (@as(anyerror, err)) { error.PermissionDenied => "permission denied", error.FileNotFound => "no file of that name", error.IsDirectory => "that is a directory, and not one that could be read", @@ -6658,82 +5210,35 @@ pub const Pardes = struct { }; const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why }); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); + _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.missing) |missing| { - // ARGV NAMED NOTHING. `+Errors` and not the message row, because a - // launch has no pane to put a message row on yet — and because - // this is exactly what acme's `+Errors` is: output from the - // program rather than from a word anybody clicked (output_pane - // `Origin.errors`). Filling the window with it makes the answer - // unmissable, which a one-line message under a shell prompt is - // not. - // - // The word AS TYPED, not a resolved path: there is nothing to - // resolve, and `pardes ~/notes/tdoo.md` wants to see its own typo - // back rather than an absolute path built around it. The pane's - // DIRECTORY is the launch directory all the same — see `missing`: - // "" would put this pane at `/+Errors` and point every word that - // reads a pane's directory at the root of the filesystem. const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", .{missing.word}, ); errdefer gpa.free(content); - _ = try output_pane.open(p, 0, missing.dir, .errors, "", content); + _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (comptime platform == .esp32p4) { - // BARE METAL BOOTS AN EMPTY OUTPUT BUFFER, and a shell is not a layout preference - // here but an impossibility: there is no operating system under this, so there is - // nothing to fork and no pty to give a terminal pane. Booting one anyway produced - // exactly what that describes - a pane whose tag ends in `Filter`, whose pty is the - // Fallback's silent one, with no gutter, no buffer, and no key that reaches anything. - // Measured on an ESP32-P4 over the serial line: every keystroke vanished. - // - // An output buffer is the right default rather than a file pane, and not only because - // `opts.file` cannot work here (the P4's embedded allowlist is empty by design - see - // source_manifest.zig - so `look.readFile` has nothing to resolve a path against). It - // is what the platform's own words WANT: `Peek`, `Poke` and `Hexdump` each fill an - // output buffer, so booting into one means the first dump lands in the same kind of - // pane the boot pane already is. It is editable text with no file behind it, which is - // the honest description of a buffer on a board with no filesystem. - // AND IT BOOTS WITH SOMETHING IN IT. An empty buffer is honest and useless: the three - // words that make this board interesting take an address, and a board's address space is - // precisely the thing you cannot guess. - // - // SHORT, because the window is fourteen rows. The first draft opened with four lines of - // prose about there being no operating system, which is true, unhelpful, and cost a - // third of the screen before the first command. One header line earns its place; the - // rest of the screen is addresses. - // - // Each command sits alone on its line because an argument list ends at the last argument - // - a trailing comment would be `ExtraArgument` - so the labels go above. Every address - // is from this repository or from a session that read it on this die: the flash and RAM - // figures are the linker script's own ORIGINs (`05-zig-p4/build.zig`), the peripheral - // bases are the `DR_REG_*` values `05-zig-p4/src/hal` uses, and the two LP addresses at - // the end are named in ESP-IDF's own headers: 0x5011002c is LP_SYSTEM_REG_LP_STORE0, a - // general-purpose retention register that holds what you put in it, and 0x501101a4 is - // LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate - // the whole point of a volatile read: one address gives back what was written and the - // other never gives the same answer twice. Both verified on this die. const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); - _ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content); + _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.tty_only) { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = try p.newShell(0, ""); @@ -6742,23 +5247,23 @@ pub const Pardes = struct { for (p.panes[0..3]) |slot| slot.?.greet = true; p.ncol = 2; p.col_n[0] = 2; - p.col_terms[0][0] = 0; - p.col_terms[0][1] = 1; + p.col_panes[0][0] = 0; + p.col_panes[0][1] = 1; p.col_n[1] = 1; - p.col_terms[1][0] = 2; + p.col_panes[1][0] = 2; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newShell(0, ""); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; - p.col_terms[0][0] = 0; + p.col_panes[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before // anyone touched anything. Nobody asked for that, so boot is silent. _ = p.takeHaptic(); @@ -6772,11 +5277,11 @@ pub const Pardes = struct { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; - for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.current) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; @@ -6793,8 +5298,7 @@ pub const Pardes = struct { p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); - if (p.presented_cells.len > 0) gpa.free(p.presented_cells); - if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs); + p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -6830,115 +5334,103 @@ pub const Pardes = struct { return config.topbar_str; } - /// Drop a pane: its slot is freed for reuse now, but the allocation is - /// doomed and actually torn down a frame later (reapPanes), so pointers to - /// it survive the frame. Callers still null `panes[id]` themselves. - pub fn deinitPane(p: *Pardes, pane: *Pane) void { - // Logical close, while the pane is still installed: stop the file/PDF - // watch keyed to this slot and drop any hover it owns. The heap - // teardown is deferred (reapPanes) so pointers to it survive the frame. - const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane); + fn detachCwds(p: *Pardes, parents: []const *Pane) !void { + var copies: [MAX_PANES]?[]u8 = @splat(null); + errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const source = switch (pane.cwd) { + .inherited => |parent| parent, + else => continue, + }; + for (parents) |parent| if (source == parent) { + copies[id] = try p.gpa.dupe(u8, paneDir(source)); + break; + }; + } + for (copies, 0..) |copy, id| if (copy) |bytes| { + p.panes[id].?.cwd = .{ .owned = bytes }; + }; + } + + // The slot closes now; allocation teardown waits one frame. Caller clears panes[id]. + pub fn deinitPane(p: *Pardes, pane: *Pane) !void { + try p.detachCwds(&.{pane}); + p.retirePane(pane); + } + + fn retirePane(p: *Pardes, pane: *Pane) void { + const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - // A pane's filesystem state dies WITH the pane, here, while the - // slot still names it: the alternative is a script that held its - // `event` file open leaving the editor suppressing button actions - // for whatever pane lands in this slot next. p.fs.forget(p.gpa, id); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); - p.pane_alloc.doom(pane); + p.retired_panes.retire(pane); } - /// The real teardown, run by reapPanes once the pane has been doomed for a - /// full frame (or at deinit). Frees every heap payload the pane owns. + // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); } p.shell_rows.dropPane(pane); - term_pane.deinitPendingCommand(pane); + panes.Terminal.deinitPendingCommand(pane); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } - if (comptime pdf_enabled) if (pane.pdf) |*pv| pdf_pane.deinitPane(p, pane, pv); - if (pane.file) |*file| file_pane.deinit(p, pane, file); + if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); + if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - term_pane.deinitEmulator(pane, p.gpa); + panes.Terminal.deinitEmulator(pane, p.gpa); + pane.clearCwd(); p.gpa.destroy(pane); } - /// Once a frame: repair live panes' pointers to doomed panes, then free the - /// panes doomed a full frame ago. Fixup runs first so no pointer outlives - /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale. fn reapPanes(p: *Pardes) void { - for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane); - for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| { + for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; - p.pane_alloc.stale = p.pane_alloc.fresh; - p.pane_alloc.fresh = @splat(null); - } - - /// Visit each Pane field that holds a pane pointer; when it names a doomed - /// pane, snapshot that pane's directory into our own bytes so the link can - /// die with it. One field carries a pointer today (the inherited cwd); the - /// comptime walk keeps that honest as fields come and go. - fn fixupPaneRefs(p: *Pardes, pane: *Pane) void { - inline for (@typeInfo(Pane).@"struct".fields) |f| { - if (f.type == Pane.Cwd) switch (@field(pane, f.name)) { - .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)), - else => {}, - }; - } + p.retired_panes.previous = p.retired_panes.current; + p.retired_panes.current = @splat(null); } - /// Put a fully constructed pane in a free slot and give it the monotonic - /// identity every slot-reuse guard relies on. Pane kinds construct their - /// own payloads; this registration rule remains a core invariant. - fn installPane(p: *Pardes, id: usize, pane: *Pane) void { + pub fn installPane(p: *Pardes, id: usize, pane: *Pane) void { std.debug.assert(p.panes[id] == null); p.next_serial += 1; pane.serial = p.next_serial; + p.rects[id] = .{}; p.panes[id] = pane; } - /// Allocate a pane slot with a live terminal emulator and queue the spawn - /// effect (optionally in a directory); the shell answers by forking a pty - /// and wiring reads back as Event.output for this pane id. pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); - if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) - const pane = try term_pane.create(p.gpa, p.screen_w, p.screen_h); - term_pane.armShellSpawn(pane); + if (cwd.len > effect_path_cap) return error.PathTooLong; + const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } - /// a doc pane (file/image/PDF): no pty and no spawn. Whatever emulator half - /// it still needs is term_pane's business — see `createDoc` there. pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); - const pane = try term_pane.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; } - /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd - /// a live link to the opener so Save can prefill that directory. New and - /// Newcol place it (below, or in a column). Installed in slot `free`. fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { const src = p.panes[from_id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); - const np = try output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); + const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; np.cur_pinned = true; return np; @@ -6947,25 +5439,25 @@ pub const Pardes = struct { /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; - const sf = p.layoutFindTerm(from_id) orelse return; + const sf = layout.findPane(p, from_id) orelse return; const np = p.newScratch(from_id, free) catch return; - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(from_id, np); + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, np); p.active = free; } /// Newcol: a scratch in a fresh column beside the calling pane. pub fn newScratchColumn(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; - if (!p.layoutCanSplitColumn(from_id)) return; + if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(p.layoutSplitColumn(from_id, free, false)); + std.debug.assert(layout.splitColumn(p, from_id, free, false)); p.active = free; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { - if (slot == null) break i; + if (slot == null and !p.reserved_slots[i]) break i; } else null; } @@ -6976,14 +5468,6 @@ pub const Pardes = struct { } else null; } - /// `id` just performed a look: put it on top of the walk's spine. - /// - /// MOVE to the top rather than push, so a pane you keep looking out of - /// stays one entry instead of filling the list with itself — the walk's - /// order is "which panes, most recent first", not "how many times". - /// Dropping the oldest when full can only ever discard a DEAD pane's - /// serial: MAX_PANES entries with no duplicates already names every slot - /// there is. fn noteLookSource(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; var w: usize = 0; @@ -7001,25 +5485,12 @@ pub const Pardes = struct { p.look_walk_owner = pane.serial; } - /// Arm n/N on a concrete live pane without pretending that pane has - /// already performed a Look. Result producers use this when a fresh or - /// refilled list supersedes older walk history. An empty answer cannot - /// supersede anything: it has no position to resume, so preserve the pane - /// whose prior Look established the walk instead of stealing provenance. pub fn armLookWalk(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file) |file| if (file.content.len == 0) return; p.look_walk_owner = pane.serial; } - /// Chunk arbitrary-length bytes into fixed-size write effects, order kept. - /// - /// The ring refuses when full rather than evicting, which is right for every - /// other effect and WRONG for a byte stream: the tail of a large paste was - /// dropped where the program needed it whole (and, under bracketed paste, the - /// closing marker went with it, leaving the program in paste mode). What does - /// not fit parks in `pending_write` and `nextEffect` feeds it back as the host - /// drains, so this never truncates while `pending_write_cap` has room. pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; // Anything already parked for this pane owns the stream's position, so @@ -7034,9 +5505,6 @@ pub const Pardes = struct { if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } - /// Take ownership of bytes the ring had no room for. A failed allocation or - /// an exhausted cap degrades to the old behaviour — dropping the tail — because - /// the alternative on a full heap is refusing to run at all. fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; @@ -7059,62 +5527,32 @@ pub const Pardes = struct { /// changes it observes, e.g. via /proc//cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; - const n = @min(cwd.len, pane.cwd_buf.len); const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; - if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return; + if (std.mem.eql(u8, cur, cwd)) return; + pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (p.lookHoverPane() == id) p.cancelLookHover(); - pane.setOwnedCwd(cwd[0..n]); - } - - /// Can a command line be typed into this pane RIGHT NOW: a terminal whose - /// tty still belongs to the prompt the host forked. A terminal running vim - /// answers false and is then treated exactly like a document pane — the - /// command goes to some other shell (ttyForDir), because keystrokes are all - /// a full-screen program would make of it. - /// - /// The occupancy half of that question is the host's to answer (look.ttyTaken - /// walks the processes under the pane's shell pid against the tty's - /// foreground process group) and it is asked HERE, lazily: only for a pane a - /// command line is about to go to, and only at the moment it is about to go - /// there. It used to be pushed in by every host on every frame for every - /// pane, which bought nothing — nothing else in the core has ever wanted the - /// answer, and a verdict one frame old is a worse one than a verdict taken - /// now. The cheap half is tested first, so a pane in the wrong directory - /// costs no syscalls at all. - /// - /// No query (web has no processes, a dump replay has no shells yet, and the - /// core's own tests install their own) means every terminal is a prompt, - /// which is exactly how pardes behaved before the probe existed. + } + fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; return !p.hostTtyTaken(id); } - /// What a shell should exec for the next terminal — a bare name to be - /// looked up, or an absolute path to be used as it stands. The resolving - /// is the shells' half: the core has no filesystem to ask. pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.default_shell else selected; } - /// The native host resolved the request (or chose a fallback) for a real - /// spawn. Record the executable that actually ran; web never calls this - /// because it has no process backend. pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { - if (id < MAX_PANES) term_pane.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; } - /// Small backend reads over the same plain state the builtins mutate. - /// Take the newest unresolved Font request once. `pending` remains true - /// until the host explicitly acknowledges success or rejection; requested - /// name/path remain queryable either way. pub fn takeFontRequest(p: *Pardes) ?[]const u8 { if (comptime !font_picker) return null; if (!p.settings.font.pending or p.font_request_taken) return null; @@ -7124,15 +5562,11 @@ pub const Pardes = struct { return path; } - /// Record the face a GUI host is currently wearing. Boot, zoom and backing - /// scale changes are observations, not answers to a pending Font request: - /// only acknowledgeFont may resolve one after takeFontRequest handed it to - /// the host. pub fn observeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, - unit: FontSizeUnit, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.effective_name.set(effective_name)) return false; @@ -7147,7 +5581,7 @@ pub const Pardes = struct { p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, - unit: FontSizeUnit, + unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.pending or !p.font_request_taken) return false; @@ -7160,9 +5594,6 @@ pub const Pardes = struct { if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false; p.settings.font.pending = false; p.font_request_taken = false; - // The host has already replaced its face/atlas at this boundary. Even - // when the new face measures to the same grid, a frozen old panel - // layer cannot truthfully be rasterized through those new glyphs. if (changed) p.abandonPanelAnimations(); return true; } @@ -7180,9 +5611,6 @@ pub const Pardes = struct { return p.theme_file_generation; } - /// Resolve and queue `ThemeFile` without doing filesystem work in the - /// core. Relative paths belong to the per-user config directory; absolute - /// paths remain useful for trying a file elsewhere. pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { if (comptime !hosted) return; const input = std.mem.trim(u8, argument, " \t\r\n"); @@ -7244,9 +5672,6 @@ pub const Pardes = struct { p.reportError(request.pane, "theme file", err); } - /// Parse and atomically wear one exact ZON snapshot. Parse failure leaves - /// the last valid custom/built-in theme untouched. `animate` is false for - /// the launcher's pre-frame drain and true for an interactive load/reload. pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { @@ -7277,7 +5702,6 @@ pub const Pardes = struct { p.chrome_animation.snap(target_chrome); const old = p.custom_theme; p.custom_theme = parsed; - p.custom_theme_active = true; if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); p.invalidateThemeDependentRasters(); _ = p.scratch.reset(.retain_capacity); @@ -7285,37 +5709,61 @@ pub const Pardes = struct { return true; } - /// Post the transient message on `id`'s last row. Called by a SHELL once - /// the IO it narrates has actually happened, exactly as the shell completes - /// a `save_file` effect: the core neither writes files nor owns a - /// clock, so both the outcome and the wall time in `text` come from there - /// (message.zig spells it, once, for both native shells). - /// - /// Nothing here decides WHEN it goes away — update does, on the next key or - /// mouse event — and nothing here knows whether the row is free: an armed - /// prompt simply outranks a message at render time, so a message posted - /// under one is stored and invisible rather than refused. - /// A message row that is NOT worth remembering: unsolicited progress from a - /// language server, which arrives several times a second for the whole of a - /// large index. - /// - /// `rust-analyzer: Indexing 47% core` is a different string every tick by - /// construction, so no de-duplication can collapse it, and at the client's - /// throttle of one per 150ms per server it takes about NINETEEN SECONDS to - /// push every save, error and reload out of a 128-entry ring. A log that - /// one indexing run empties is not a log. Progress belongs on the row, - /// where it is read as it happens and then replaced; the log is for things - /// that were said once. + pub const Message = struct { + const libc = std.c; + const Tm = extern struct { + sec: c_int, + min: c_int, + hour: c_int, + mday: c_int, + mon: c_int, + year: c_int, + wday: c_int, + yday: c_int, + isdst: c_int, + gmtoff: c_long, + zone: ?[*:0]const u8, + }; + extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; + + pub fn body(text: []const u8) []const u8 { + if (text.len < 10) return text; + if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; + for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { + if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; + } + return text[10..]; + } + + pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { + var clock: [8]u8 = "--:--:--".*; + const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; + if (!notime) { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.REALTIME, &ts); + const secs: libc.time_t = ts.sec; + var tm: Tm = undefined; + if (localtime_r(&secs, &tm) != null) { + _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ + @as(u32, @intCast(tm.hour)), + @as(u32, @intCast(tm.min)), + @as(u32, @intCast(tm.sec)), + }) catch {}; + } + } + const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; + const room = buf.len - head.len; + const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; + @memcpy(buf[head.len..][0..tail.len], tail); + return buf[0 .. head.len + tail.len]; + } + }; + pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { p.showMessage(id, text); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { - // LOGGED FIRST, and logged even when the pane is gone. A message row - // is cleared by the next keystroke (see `clearMessages`), so anything - // said while the user was looking elsewhere — a save that failed, a - // watcher's reload, a builtin's complaint — used to be unrecoverable - // the instant it appeared. `Messages` reads this back. p.logMessage(id, text); p.showMessage(id, text); } @@ -7327,23 +5775,14 @@ pub const Pardes = struct { @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } - /// Append to the ring, oldest overwritten. No allocation and no failure: - /// this sits under every `reportError` in the program, including the ones - /// raised because an allocation just failed. fn logMessage(p: *Pardes, id: usize, text: []const u8) void { if (text.len == 0) return; const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; - // What is STORED is truncated to the slot, so what is COMPARED must be - // too: comparing a 300-byte message against its own 256-byte record - // never matched, and two identical long messages each got their own - // row. And the comparison is on `message.body` — the row without its - // clock — because a stamp makes every host message unique by - // construction, which defeated this entirely for `saved`/`reloaded`. const kept = text[0..@min(text.len, LoggedMessage.cap)]; if (p.messages_len > 0) { const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; if (last.pane == pane and - std.mem.eql(u8, message.body(last.slice()), message.body(kept))) + std.mem.eql(u8, Message.body(last.slice()), Message.body(kept))) { // The NEWEST wording wins, so the row carries the latest clock // rather than the moment the run started. @@ -7374,31 +5813,24 @@ pub const Pardes = struct { p.setMessage(id, text); } - /// Apply one watched-path snapshot to the payload which owns that path. - /// True means the pane now represents this successful host transaction; - /// native watchers commit their generation only then, so a transient PDF - /// reopen/allocation failure remains retryable. fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool { if (id >= MAX_PANES) return false; const pane = p.panes[id] orelse return false; if (comptime pdf_enabled) if (pane.pdf != null) { - pdf_pane.reloadWatched(p, pane) catch |err| { + panes.Pdf.reloadWatched(p, pane) catch |err| { p.reportError(id, "PDF reload", err); return false; }; return true; }; if (pane.file == null) return false; - file_pane.changed(p, id, bytes); + panes.File.changed(p, id, bytes); return if (p.panes[id]) |current| if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false else false; } - /// Synchronous host seam for a watched file. Event.update routes through - /// the same payload operation, while native watchers use this spelling to - /// learn whether they may commit the observed disk generation. pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); @@ -7407,9 +5839,6 @@ pub const Pardes = struct { return applied; } - /// Content replacement invalidates a preview whose operand was expanded - /// from that pane. Payload modules call this for derived buffers they - /// refresh as part of the same transaction. pub fn invalidateLookHover(p: *Pardes, id: usize) void { if (p.lookHoverPane() != id) return; p.raw_hover_intent = false; @@ -7424,9 +5853,6 @@ pub const Pardes = struct { } pub fn nextEffect(p: *Pardes) ?Effect { - // Before the emptiness test, not after: a pane whose tail is parked - // must not read as "no effects left" while the host's drain loop is - // still asking. This is what turns a truncated paste into a delayed one. p.refillPendingWrites(); if (p.effects_len == 0) { p.effects_head = 0; @@ -7470,11 +5896,6 @@ pub const Pardes = struct { p.pending_write[id] = null; } - /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an - /// event that carries a borrowed slice cannot survive the trip, so this - /// asserts rather than documents it. Hand those to `update` directly inside - /// the host's borrow window instead — which is also what keeps the pty read - /// path copy-free. pub fn postEvent(p: *Pardes, ev: Event) void { switch (ev) { .key => |k| std.debug.assert(k.text.len == 0), @@ -7497,31 +5918,21 @@ pub const Pardes = struct { return ev; } - /// Has a program taken this pane's tty? A host that cannot tell says no, - /// which is how pardes behaved before the probe existed. Public because - /// `pty/status` reports it: it is the one field of that file the core does - /// not own itself, and asking here rather than reaching for the vtable in - /// acmefs keeps the null-method default in one place. pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { - const f = p.host.vtable.pull_tty_taken orelse return false; + const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { - if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes); + if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes); // The in-process filesystem reports the same way a real host does, so // an OOM here leaves the pane dirty rather than looking saved. if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory); } - /// A HOST'S ANSWER TO `save_file`, and the only one it needs to give: the - /// write did not happen. Puts the reason on the pane's message row and - /// takes back the optimistic clean mark `perform` made, so the tag keeps - /// its ` *` and the edits keep being edits. See host.zig - /// `push_write_file` for why this is a call and not a return value. pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { - // The `-%` spelling acmefs.zig already uses for "make this dirty". + // The `-%` spelling fs.zig already uses for "make this dirty". f.saved_revision = f.revision -% 1; }; p.reportError(id, what, err); @@ -7534,14 +5945,7 @@ pub const Pardes = struct { return pane.pdfPath(); } - /// THE BYTES BEHIND AN `.fs_reply`, resolved in the drain. A filesystem - /// read answers with either something the handler formatted (staged in the - /// core, valid until the next request) or a window onto a pane's live text, - /// which is handed over WITHOUT A COPY — the same trick, and the same - /// serial check, `.save_text` uses to write a megabyte it never duplicated. - /// A slot reused between the answer and this call resolves to nothing - /// rather than to another pane's text. - pub fn fsPayload(p: *const Pardes, r: acmefs.Reply) []const u8 { + pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], @@ -7555,120 +5959,85 @@ pub const Pardes = struct { }; } - /// Perform one effect through the host, falling back per METHOD (not per - /// host) to the in-process implementation. This is the switch that used to - /// be copied into all four shells. pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { - .spawn => |s| if (v.push_spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { + .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. - .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), - .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), - // A host with no signal method has no child to signal: the - // fallback host's ptys are silent (see `.write` above), so there - // is nothing to record and nothing to lie about. - .signal_pty => |s| if (v.push_pty_signal) |f| f(p.host.ctx, s.pane, s.sig), - .open_link => |u| if (v.push_open_link) |f| + .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), + .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), + .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), + .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) else p.fallback.setLink(u.slice()), .save_file => |sf| { const pane = p.panes[sf.pane] orelse return; const f = if (pane.file) |*file| file else return; - // CLEAN HERE rather than where the effect was queued, and - // BEFORE the call so the host can take it back. `saveFile` used - // to set `saved_revision` at emit time, so a write that could - // not happen still cleared the tag's ` *` and left the edits one - // `Del` from gone — `Del` makes no dirty check. Here is also - // where `f.content` is read, so the revision recorded is the - // revision of the bytes that actually went out. + const serial = pane.serial; f.saved_revision = f.revision; p.hostWriteFile(sf.pane, f.path, f.content); + const saved_pane = p.panes[sf.pane] orelse return; + if (saved_pane.serial != serial) return; + const saved = if (saved_pane.file) |*file| file else return; + if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; + saved.watch_after_save = false; + if (filesystem.localPath(saved.path) != null) + p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); }, .save_text => |st| { const pane = p.panes[st.pane] orelse return; if (pane.serial != st.serial) return; // a recycled slot: not ours - // `Save ` is a COPY: it does not clean this pane, - // because the file the pane has open is not the file that was - // written. The one case that does clean is a scratch buffer - // adopting the path, and `saveTo` handles that by emitting - // `save_file` for the pane's own path instead. if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content); if (!pane.isTerminal()) return; - const text = term_pane.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); }, .write_dump => { const out = p.dump_out orelse return; - if (v.push_write_dump) |f| { + if (v.write_dump) |f| { f(p.host.ctx, out); } else { - // A real host reports where it landed, which is what puts - // `Restore ` in the topbar; the virtual one owes the - // same, or the bytes it holds are unreachable. - _ = p.fallback.writeFile(fallback_dump_path, out); - p.setLastDump(fallback_dump_path); + _ = p.fallback.writeFile(host_io.Fallback.dump_path, out); + p.setLastDump(host_io.Fallback.dump_path); } }, .set_clipboard => { const text = p.yank orelse ""; - if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); + if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, // which is the same shape as a host answering later. - .read_clipboard => if (v.pull_read_clipboard) |f| + .read_clipboard => if (v.read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), - .lsp => |q| if (v.pull_lsp) |f| + .lsp => |q| if (v.lsp) |f| f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) else p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), - .pipe => |q| if (v.pull_pipe) |f| + .pipe => |q| if (v.pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), - .watch => |w| if (v.push_watch_file) |f| - f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on) + .watch => |w| if (v.watch_file) |f| + f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { p.fallback.watched[w.pane] = w.on; }, - .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on), - .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane), - // The bytes are read off the core HERE, in the drain, exactly as - // save_file reads a file pane: the reply named where they live and - // this is the borrow window. A host with no filesystem serving - // cannot have asked, so a null method is not a dropped answer. - .fs_reply => |r| if (v.push_fs_reply) |f| f(p.host.ctx, &r, p.fsPayload(r)), - // THE ONE EFFECT NO HOST METHOD CAN SERVE: attaching REPLACES the - // core this call is running inside — `pump` is two frames up the - // stack — so all it may do here is record the request where the - // shell's outer loop finds it, which is Restore's shape exactly. - // Reaching it through the ring rather than straight from the - // builtin is what ORDERS it: a `Save` queued by the same update is - // performed first, so nothing you typed is still unwritten when - // the screen changes owners. A shell that never polls (the - // browser, the board) simply cannot attach, which is the truth - // about a machine with no unix socket to attach to. + .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), + .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), + .fs_reply => {}, .attach => |a| { const name = a.name.slice(); @memcpy(p.attach_buf[0..name.len], name); p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] }; }, - // ...and its counterpart, which a host CAN serve and usually does - // not. Only a detached core's host fills `push_detach` in; a local - // tty or SDL shell leaves it null, and the honest answer there is - // a message row rather than a frontend that quits or a word that - // silently does nothing. A null-method fallback and not a comptime - // gate, because whether there is a session to leave is a fact - // about this RUN — the same binary attaches one minute and does - // not the next. - .detach => |d| if (v.push_detach) |f| + .detach => |d| if (v.detach) |f| f(p.host.ctx) else p.reportError(d.pane, "detach", error.NotAttached), @@ -7677,45 +6046,29 @@ pub const Pardes = struct { } } - /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER - /// here — wait, apply input, perform effects, poll, render, present — was - /// copied into four shells and drifted in each. A host supplies the parts - /// only it can (blocking, pixels, processes) and nothing else. - /// - /// It is one PUMP and never a `while`: no host gives up its outer loop. - /// AppKit owns NSApplication's run loop, the browser owns the frame - /// callback, and both Linux hosts keep a thin one so Restore can swap the - /// whole core between frames. pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); // A quitting frame has already freed what it would draw. if (p.quit) return; - if (v.push_poll_frame) |f| f(h.ctx); + if (v.poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); - if (v.push_present) |f| f(h.ctx, surface); - if (v.push_post_present) |f| f(h.ctx); - // Animation TIME is not spent here. `wait_input` was told how long it - // may sleep; a display clock wakes faster than that on input, so only - // the host knows when a real frame interval has passed. Each spends it - // by handing back one `.tick`. + if (v.present) |f| f(h.ctx, surface); + if (v.post_present) |f| f(h.ctx); } pub fn update(p: *Pardes, ev: Event) void { - // Free a motion surface that went bad during the LAST update, before - // anything in this one can ask for it. Nothing frees it mid-update: - // handlers hand `rows` around for the length of a single update. p.shell_rows.sweep(p.gpa); - // A preview describes the frame under an idle pointer. Any state - // change can replace that text or geometry, so it cancels; buttonless - // motion is the one event that debounces/re-arms it, and ticks only - // age the current candidate. switch (ev) { .tick => {}, + .fs_req => |req| if (req.changesPane()) { + p.raw_hover_intent = false; + p.cancelLookHover(); + }, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; p.cancelLookHover(); @@ -7737,38 +6090,15 @@ pub const Pardes = struct { p.cancelLookHover(); }, } - // A transient message is exactly as old as your last input: touch the - // keyboard or the mouse and it is gone, on every pane, because a - // message is a report and you have just proved you are back. Only - // INPUT counts — a resize, pty output, a watch or an answering worker - // all repaint without you, and a message that a background shell could - // wipe would be one you never got to read. - // - // An ARMED PROMPT is a different occupant of the same row and is not - // touched here: it lives in tag_tail, it is what the keystroke is being - // typed INTO, and it ends at Enter or Esc. So clearing here can never - // fight one — at worst it clears something the prompt was already - // hiding. switch (ev) { .key, .mouse => { for (p.panes) |slot| { if (slot) |pane| pane.msg_len = 0; } - // ...and the same reasoning bounds a clipboard read in flight. - // A terminal that gates or refuses the OSC 52 request never - // answers at all, so the request cannot be allowed to sit and - // then fire minutes later into whatever pane is focused by - // then: it lives exactly until your next keystroke, and a - // shell that answers within one round trip (every one but a - // refusing tty) is unaffected. p.clip_pending = null; }, else => {}, } - // Which input this update IS, in acme's origin alphabet, so every - // event record the handlers below produce is attributed without any - // of them being told: `K` for the keyboard, `M` for the mouse. A - // filesystem write says `E`/`F` for itself (see acmefs). if (p.fs.listeners != 0) p.fs.origin = switch (ev) { .key => 'K', .mouse => 'M', @@ -7776,26 +6106,23 @@ pub const Pardes = struct { }; switch (ev) { .resize => |sz| { - p.snap_panel_layout_once = true; + p.presentation.snap_once = true; if (comptime pdf_enabled) { - var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null); + var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; - if (pane.pdf != null) before[id] = pdf_pane.paneViewport(p, pane); + if (pane.pdf != null) before[id] = panes.Pdf.paneViewport(p, pane); } p.screen_w = sz.cols; p.screen_h = sz.rows; p.cell_pixels.w = @max(1, sz.cell_pixels.w); p.cell_pixels.h = @max(1, sz.cell_pixels.h); - // Compare the effective per-pane pixel viewport, not the - // resize event itself: duplicate SIGWINCH notifications - // must not undo a reader's manual pan. - p.computeGeom(); + layout.compute(p); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { - if (!std.meta.eql(before[id], pdf_pane.paneViewport(p, pane))) { - pdf_pane.captureLayoutAnchor(pv); + if (!std.meta.eql(before[id], panes.Pdf.paneViewport(p, pane))) { + panes.Pdf.captureLayoutAnchor(pv); pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; } @@ -7809,25 +6136,20 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; - // The RAW bytes, before the emulator eats them. `pty/data`'s - // read is the only thing that wants them — the grid is a - // rendering and cannot be un-rendered — and this is one load - // and one branch on a pane nobody is reading. See - // `acmefs.notePtyOutput`. - acmefs.notePtyOutput(p, o.pane, o.bytes); - term_pane.feedOutput(p, pane, o.bytes); + filesystem.notePtyOutput(p, o.pane, o.bytes); + panes.Terminal.feedOutput(p, pane, o.bytes); + }, + .eof => |e| p.removePane(e.pane) catch |err| { + if (p.panes[e.pane]) |pane| pane.mode = .normal; + p.reportError(e.pane, "terminal exited; Del retries close", err); }, - .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs, r.failure), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| { - // Layout is only committed on RELEASE. Mark that one frame as - // a direct-manipulation snap before dragRelease mutates the - // weights; text/tag/PDF drags never touch panel presentation. if (m.kind == .release) switch (p.drag) { - .border_v, .border_h, .move => p.snap_panel_layout_once = true, + .border_v, .border_h, .move => p.presentation.snap_once = true, else => {}, }; p.handleMouse(m); @@ -7836,24 +6158,21 @@ pub const Pardes = struct { if (comptime pdf_enabled) { if (scroll.pane < MAX_PANES) { if (p.panes[scroll.pane]) |pane| { - if (hasPdf(pane) and p.native_images) - _ = pdf_pane.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); + if (pane.hasPdf() and p.native_images) + _ = panes.Pdf.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); } } } }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), - // One filesystem request in, one answer out, in this update. The - // whole of the concurrency is that the transport asked from the - // loop thread; see acmefs.zig's header. - .fs_req => |r| p.emit(.{ .fs_reply = acmefs.handle(p, r) }), + .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => p.pointer_inside = false, .tick => { p.chrome_animation.advance(); - p.advancePanelAnimations(); + p.presentation.advance(); p.advanceLookHover(); }, } @@ -7862,15 +6181,6 @@ pub const Pardes = struct { p.fsReport(); } - /// TAG EDITS, which no single call site owns: a tag is assembled from a - /// live prefix and an editable tail by half a dozen paths (typing, a prompt - /// arming, a Save clearing the dirty marker, a shell reporting a new cwd), - /// so it is diffed at the END of an update, where it is finally settled. - /// acme can hook `textinsert` on the tag itself because its tag IS a text - /// buffer; pardes's is a rendering, so the diff is the honest equivalent. - /// - /// Costs nothing when nobody is listening: one branch, and the snapshots - /// are only allocated for panes a script has opened. fn fsReport(p: *Pardes) void { if (p.fs.listeners == 0) return; for (p.panes, 0..) |slot, id| { @@ -7879,30 +6189,14 @@ pub const Pardes = struct { const tag = p.tagText(p.scratch.allocator(), pane) catch continue; const snap = &p.fs.panes[id].tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; - // First sight of a tag is not an edit: the script just opened the - // file and can read `tag` for itself. `release` drops the snapshot - // with the last reader, so this stays true across re-opens. if (snap.capacity != 0 or snap.items.len != 0) - acmefs.noteReplace(p, id, true, snap.items, tag); + filesystem.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } } - /// THE DEFAULT REGISTER, and nothing else. helix: an ordinary `y`/`d`/`c` - /// writes here and the system clipboard never hears about it — which is - /// also the bug this spelling fixes, because a mirror on every write made - /// deleting one character clobber whatever the desktop was holding. - /// `SPC y` is the command that crosses over (setClipboard below). fn setYank(p: *Pardes, text: []const u8) void { - // Inside a multi-selection replay the register collects EVERY range's - // text, in document order — the passes run last-range-first, so each - // new piece goes in front of what is already there. - // ponytail: helix keeps one register VALUE per range and pastes - // value[i] back at range[i]; pardes has a single register, so N - // cursors yank one newline-joined blob and a paste puts that whole - // blob at every cursor. Written down as a differential waiver rather - // than faked — a per-range register is its own feature. if (p.multi_on and !p.multi_first) { const old = p.yank orelse ""; const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; @@ -7915,16 +6209,6 @@ pub const Pardes = struct { p.yank = p.gpa.dupe(u8, text) catch null; } - /// ...and the register PLUS the system clipboard, which is the whole - /// difference between `y` and `SPC y`. - /// - /// One mirror per KEYSTROKE rather than per cursor: a multi-selection - /// replay runs last-range-first and `multi_first` marks its first pass, so - /// emitting there queues exactly one effect — and the shell reads - /// `core.yank` when it DRAINS, by which time every later pass has folded - /// its range in. That asymmetry used to be a silent hole: the old mirror - /// sat past the join's early return, so a multi-cursor yank reached the - /// clipboard on one cursor and not on two. fn setClipboard(p: *Pardes, text: []const u8) void { p.setYank(text); if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); @@ -7933,32 +6217,19 @@ pub const Pardes = struct { /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. pub const ClipRequest = struct { pane: usize, - /// the pane's identity, not its slot: the answer can arrive whole - /// keystrokes later (a tty's OSC 52 round trip) and a freed slot is - /// reused by an unrelated pane. serial: u32, mode: enum { after, before, replace }, }; - /// `SPC p` / `SPC P` / `SPC R`: ask the shell for the system clipboard and - /// remember what to do with it. The request is deliberately fire-and-hope - /// — a terminal that refuses the OSC 52 read simply never answers, and the - /// next keystroke drops the request (see update) rather than letting a - /// paste land minutes late in whatever pane is focused by then. pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void { const pane = p.panes[id] orelse return; p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode }; p.emit(.read_clipboard); } - /// Type text at a pane's program, the way a terminal emulator pastes: - /// bracketed when the app set mode 2004 (readline/vim/helix strip the - /// markers and refuse to run what arrives), else with `\n` turned to `\r`, - /// because a raw newline in an unbracketed paste IS the Enter key and a - /// multi-line paste would run every line but the last. pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; - if (term_pane.bracketedPaste(pane)) { + if (panes.Terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); @@ -7971,11 +6242,6 @@ pub const Pardes = struct { p.emitWrite(id, cp); } - /// The shell answered with system-clipboard text — or the desktop pasted - /// into us unasked. Either way the bytes are pasted WITHOUT going through - /// the register: helix's clipboard commands and the default register are - /// separate stores, and a paste that quietly overwrote your `y` would be - /// the same clobbering bug in the other direction. fn applyPaste(p: *Pardes, bytes: []const u8) void { const req = p.clip_pending; p.clip_pending = null; @@ -7984,30 +6250,19 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; - // A pane in tty mode has no editable buffer to paste INTO — the pty - // owns its screen. Type the bytes at the program instead, which is - // also what makes a desktop paste (Ctrl-Shift-V, middle click, the - // window manager's own) reach a shell at all. if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { - .after => p.pasteText(pane, bytes, false), - .before => p.pasteText(pane, bytes, true), + .after => p.pasteText(pane, bytes, false, 1), + .before => p.pasteText(pane, bytes, true, 1), .replace => p.replaceWithText(pane, bytes), } } - /// `SPC y` / `SPC Y`: the selection to the system clipboard. `main_only` - /// is helix's capital — every cursor's text joined, versus the primary - /// selection's alone. A PDF has no editable buffer to replay over, so its - /// own selection answers directly. pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (comptime pdf_enabled) if (pane.pdf) |pv| { if (pv.selection_text.len > 0) p.setClipboard(pv.selection_text); return; }; - // the ordinary `y` path, so what reaches the clipboard is exactly what - // the key would have put in the register — including the multi-cursor - // join, which is replaySels' business and not a second implementation if (pane.nsel > 0 and !main_only) { p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { @@ -8019,11 +6274,6 @@ pub const Pardes = struct { p.emit(.{ .set_clipboard = {} }); } - /// A per-KEYSTROKE action reached from inside a per-SELECTION replay — - /// one that opens, closes or focuses a pane, or asks the language backend. - /// It must happen once rather than once per cursor, and once it has, the - /// remaining passes are meaningless (the pane they would edit may be gone), - /// so the replay stops. True = this pass may go ahead. fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; @@ -8032,16 +6282,6 @@ pub const Pardes = struct { // ---- tag + selection text (chord sources) ---- - /// the live tag prefix: the pane's cwd/path, plus pane-local state whose - /// owner reports it (PDF view and image renderer choices). Those choices - /// mutate only through builtins under SPC t. The mode used to lead this as a word; it is - /// the one character in the layout box now (renderPane), so every tagline - /// starts four columns further left and spends them on the path instead. - /// - /// Arena-allocated like everything the renderer is handed — the tag is - /// retained through the frame and a cwd can be rewritten under us by the - /// next shell report, so this owns its bytes rather than lending the - /// pane's. pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( @@ -8049,9 +6289,9 @@ pub const Pardes = struct { "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}", .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path }, ); - if (pane.image) |*state| return image_pane.tagPrefix(arena, state); + if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { - if (output_pane.fileTraits(f.output).saves and f.revision != f.saved_revision) + if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); return arena.dupe(u8, f.path); } @@ -8064,13 +6304,6 @@ pub const Pardes = struct { return defaultTail(pane); } - /// The untouched command tail for this pane class. `curTail` and tagGap - /// must ask the same question: otherwise a terminal renders Filter but - /// still votes for the shorter generic tail when a column is aligned. - /// - /// Save leads wherever the pane holds text of its own — every pane with a - /// file, an output buffer included, plus every terminal. What is left is an - /// image and a PDF: their bytes on disk already are what they are. fn defaultTail(pane: *const Pane) []const u8 { if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; @@ -8084,60 +6317,6 @@ pub const Pardes = struct { return null; } - /// Spaces to sit between the path and the commands, so the commands END - /// `tag_right_pad` columns short of the pane's edge — right-aligned, with - /// that many columns left free to type in. - /// - /// Real spaces rather than a second print at an offset, because the tag is - /// ONE buffer that tag_col, the mouse, the motions and the chord all index - /// by the same columns; two separately-positioned pieces would need a - /// column-to-offset map that none of them has. Being characters is also - /// what makes both paddings editable, which is the point: `:` lands at the - /// start of this gap, and there are `tag_right_pad` free columns past the - /// commands to type into. - /// - /// Zero once the tag has been touched. From then on the spaces are IN - /// tag_tail and belong to you — recomputing would both double them and - /// slide the commands sideways under your cursor as you type. So an - /// untouched tag reflows with the pane and an edited one stays put. - /// - /// The end column is shared by the whole LAYOUT COLUMN — every DRAWN pane - /// at the same x and width, which is exactly the set whose taglines sit - /// above one another on screen (`h == 0` is a pane squeezed off the bottom - /// by a shrunk window: renderPane returns early on it, so it has no words - /// to line up with and gets no vote). `tag_right_pad` sets the end, but a - /// path too long to fit inside that pad used to collapse only ITS pane's - /// gap to zero, which left one row's commands jammed against the path while - /// the row below kept its out at the pad. Now the column moves out - /// together, so the words stay in a line and a click walks down them. - /// - /// Alignment is measured in display cells. Cursor and selection state use - /// UTF-8 byte offsets, then map through the same grapheme-width helpers at - /// the screen boundary. - /// - /// Two rules make up the "when possible": - /// - /// - a voter that does not FIT is counted at the pane's right edge rather - /// than dropped. Dropping it would leave the rest of the column its - /// typing room, but it is a threshold: one column of resize either side - /// of the fit moves every tagline in the column by the whole pad, and - /// dragging a window edge across that width snaps the words back and - /// forth under the pointer. Clamping buys a crossing one column wide - /// and monotone, and the price is a column of taglines that can end - /// hard against the right edge with nothing left over to type in. - /// - a TOUCHED tag votes with the end it was FROZEN at, not with its live - /// tail: the gap is baked into tag_tail as leading spaces, and counting - /// what you type after the builtins would drag the column sideways on - /// every keystroke. It still takes no gap of its own (above) — but it - /// has to keep voting, or clicking the widest tagline in a column would - /// snap every other one left, out from under the next click. - /// - /// ponytail: every voter's prefix is FORMATTED to be measured, so a frame - /// costs up to MAX_PANES² path dupes — 256 bump allocations into the - /// scratch arena that renderPane resets anyway, at a realistic two to four - /// panes. The alternative was a second tagPrefix that only counted, and - /// keeping two spellings of one string in step by comment is the more - /// expensive kind of cost. fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize { if (pane.tag_init) return 0; const id = p.paneIdOf(pane) orelse return 0; @@ -8148,25 +6327,19 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, qid| if (slot) |q| { const qr = p.rects[qid]; if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue; - // prefix ++ the spaces in front of the words ++ the words: the - // frozen gap for a touched tail, the default's own single leading - // space for an untouched one (which is why there is no +1 here) const words = defaultTail(q); const laid = if (q.tag_init) q.tagSlice() else words; const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; - const q_end = file_pane.displayWidth(p.tagPrefix(q) catch continue) + lead + file_pane.displayWidth(std.mem.trimStart(u8, words, " ")); + const q_end = panes.File.displayWidth(p.tagPrefix(q) catch continue) + lead + panes.File.displayWidth(std.mem.trimStart(u8, words, " ")); end = @max(end, @min(q_end, tw)); }; return end -| used; } - /// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text - /// tag_col and tag_anchor index, so the renderer, the mouse, the motions - /// and the chord all read the same bytes at the same columns. pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); - const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); + const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); const out = try arena.alloc(u8, prefix.len + gap + tail.len); @memcpy(out[0..prefix.len], prefix); @memset(out[prefix.len..][0..gap], ' '); @@ -8174,14 +6347,11 @@ pub const Pardes = struct { return out; } - /// Take the laid-out tail into the pane's own buffer, once, on first touch. - /// The gap comes along as ordinary characters — that is what hands the - /// padding to you to edit, and what freezes it against reflow from here on. pub fn seedTail(p: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); const prefix = (p.tagPrefix(pane) catch return); - const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail)); + const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); if (gap + tail.len > pane.tag_tail.len) return; @memset(pane.tag_tail[0..gap], ' '); @memcpy(pane.tag_tail[gap..][0..tail.len], tail); @@ -8189,11 +6359,6 @@ pub const Pardes = struct { pane.tag_init = true; } - /// focus the tag for editing, seeding the tail on first touch and parking - /// the byte cursor at the grapheme displayed under screen column `col`. - /// NEGATIVE means the tail's first WORD, which is where `:` and a tagline - /// hop land: a place no click can name, so it needs no sentinel of its own - /// and the callers need no prefix length. fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); @@ -8203,28 +6368,19 @@ pub const Pardes = struct { pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); - // Past the gap that right-aligns the builtins, not at the first - // editable column: `:` promises the tail's START, and the start of a - // run of layout spaces is not it. Landing there would cost `:w` its - // second keystroke — w would select the whitespace and execute nothing - // — and `:w` being the same two keys every time is the whole point of - // the door. The spaces stay editable; h and Left still walk into them. const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { pane.tag_col = @intCast(@min(edit0 + lead, end)); } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(col)))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(col)))); } } fn exitTagEdit(pane: *Pane) void { - // tags are always insert; leaving one restores the body mode: files - // back to normal, terminals to whatever they had — a click (this runs - // on every body press) must never change a shell pane's mode if (pane.isTerminal()) { if (pane.tag_edit) pane.mode = pane.tag_mode; } else pane.mode = .normal; @@ -8240,9 +6396,6 @@ pub const Pardes = struct { return .{ .lo = @min(a, c), .hi = @max(a, c) }; } - /// the text a tag Enter/Tab chord (and `y`) acts on: the char selection, - /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so - /// the path is a word like any other — Enter on it looks it. fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { const text = p.tagText(p.scratch.allocator(), pane) catch return null; if (pane.tag_sel) { @@ -8254,9 +6407,6 @@ pub const Pardes = struct { return if (b.hi > b.lo) text[b.lo..b.hi] else null; } - /// tag-edit key dispatch: a modal one-line editor over the rendered tag, - /// sharing the pane's mode — the cursor moves over all of it, edits reach - /// only the tail. Newlines are always dropped. fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it @@ -8270,9 +6420,6 @@ pub const Pardes = struct { } return; } - // the prefix is live chrome, not text you own: the cursor may sit in it - // (that is how the path selects), but every edit below is measured from - // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); if (key.text.len > 0) { @@ -8311,29 +6458,19 @@ pub const Pardes = struct { /// write a helix range back onto the tag cursor + selection: the rendered /// tag's one-line mirror of setPaneRange. - fn setTagRange(pane: *Pane, text: []const u8, r: modal.HxRange) void { + fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); - pane.tag_col = @intCast(modal.hxCursor(text, r)); + pane.tag_col = @intCast(modal.selectionCursor(text, r)); pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); } - /// normal mode ON the tag — where `:` lands. The body's own helix motions - /// with the WHOLE rendered tag as a one-line document (so the path selects - /// like any other text, and Enter on it looks it), plus insert entry and - /// the acme chords; editing keys stay in insert (`i` then type, like the - /// mouse path) and never reach left of `edit0`. fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = p.tagText(p.scratch.allocator(), pane) catch return; const cur: usize = @min(@as(usize, pane.tag_col), text.len); // Esc abandons the command line: back to the body, tail kept as text if (hit(key, config.escape)) return exitTagEdit(pane); - // the chord: run the selection (or the word under the cursor) and drop - // back into the body — the whole point of `:`. The same two bindings as - // everywhere else (config.look_key / exec_key), so the command line is - // `:w` by default. - // Nothing under the cursor means nothing ran: the tag keeps focus. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; @@ -8342,23 +6479,10 @@ pub const Pardes = struct { p.runBuiltin(cmd, id, "", txt); return; } - // y — yank what the chord would run: the selection, else the word under - // the cursor. The path is selectable, so this is how you copy it out. - // - // The ONE register write that still mirrors to the system clipboard - // without `SPC` in front of it, and it is not an exception so much as - // the only spelling available: a tag is always in insert mode, the - // leader is body-normal only, so `SPC y` cannot be pressed here — and - // "copy this path somewhere else" is the entire reason the chord - // exists. A path that only reached the internal register would be a - // key that does nothing you can observe. if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setClipboard(txt); return; } - // insert entry (one line, so I/A are the tail's ends). The prefix is - // read-only, so entering insert inside it parks at the first editable - // column instead — you can never be typing into the path. if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { @@ -8375,15 +6499,6 @@ pub const Pardes = struct { pane.mode = .insert; return; } - // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk - // it: focus the neighbour and land on ITS tagline, still in normal mode, - // so tag-to-tag navigation never drops through a body. Runs the SAME - // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the - // SAME table (config.window_keys) — one focusDir, one binding. Only the - // LETTER column: the arrows keep the in-tag grapheme motion below, so - // the letters cost nothing. Nothing in that direction = stay put, tag - // and all: focusDir left `active` alone, so there is nothing to undo — - // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = for (config.window_keys) |wk| { if (hit(key, &.{wk.letter})) break wk.cmd; } else null; @@ -8391,11 +6506,6 @@ pub const Pardes = struct { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { - // above the topmost tagline is the TOPBAR — row 0, the global - // one. It is not a pane, so focusDir can never reach it; this - // one fallback is what makes `k` walk off the top of the - // layout instead of dying there. Only from a tagline: a body's - // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; @@ -8411,14 +6521,7 @@ pub const Pardes = struct { if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } - /// the one-line normal-mode motion vocabulary as a helix range over `text`, - /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` - /// (Home/End) to the ends, and the word motions, which select the span they - /// traverse exactly like the body's. A pane's tag and the TOPBAR are the - /// same one-line normal mode over different bytes, so the keys are read in - /// one place; only `h`/`l` differ — a tagline spends them on the layout, - /// the topbar has no layout — and each caller answers those itself. - fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { + fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) @@ -8447,16 +6550,9 @@ pub const Pardes = struct { else null; const t = wt orelse return null; - return modal.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); + return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } - /// normal mode ON the topbar — row 0, where `k` off a top-row tagline - /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` - /// as plain grapheme motion: up here they have no neighbouring window to - /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor - /// through the very dispatch a middle click on it uses, and `j` drops back - /// onto a tagline. The bar is chrome with no tail of its own, so there is - /// deliberately no insert mode and no selection: focus, move, run, leave. fn topbarKey(p: *Pardes, key: Key) void { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); @@ -8467,19 +6563,12 @@ pub const Pardes = struct { p.topbar_col = null; // the active pane still has its body focus return; } - // the chord: run the word under the cursor, exactly as a middle click - // on it does. Leave the bar FIRST — `Kill` lives up here and tears the - // session down, the same hazard the pane-tag chord has with `Del`. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, cur); p.topbar_col = null; if (word.len > 0) _ = p.execute(p.active, word); return; } - // j — back down onto a tagline, the mirror of the k that got you here: - // the pane you came from if it still holds the top row, else the - // leftmost pane that does. Recomputed, never remembered, so a pane - // deleted while the bar had focus strands nobody. if (hit(key, config.topbar_down)) { var dest: ?usize = null; for (p.panes, 0..) |slot, i| { @@ -8503,7 +6592,7 @@ pub const Pardes = struct { else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| - modal.hxCursor(bar, r) + modal.selectionCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the @@ -8519,7 +6608,7 @@ pub const Pardes = struct { /// block-selected text, newline-joined per row; reads the rendered screen /// so typed text and shell output select identically. Scratch-owned. - fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 { + fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); @@ -8532,8 +6621,8 @@ pub const Pardes = struct { var count_row: i32 = 0; while (count_it.next()) |line| : (count_row += 1) { if (count_row < r0 or count_row > r1) continue; - const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len); - const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, count_row, line, c1), line.len)); + const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len)); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } @@ -8549,18 +6638,14 @@ pub const Pardes = struct { at += 1; } first = false; - const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len); - const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, v, line, c1), line.len)); + const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len); + const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len)); @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); at += b1 - b0; } return out; } - /// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end - /// may be given first — a selection swept upwards has its anchor after its - /// head — and the coordinate SYSTEM is the caller's: screen cells for a - /// mouse selection, absolute rows for a modal one. fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool { const fwd = ar < br or (ar == br and ac <= bc); const sr, const sc = if (fwd) .{ ar, ac } else .{ br, bc }; @@ -8570,10 +6655,6 @@ pub const Pardes = struct { const ExpandedWord = struct { lo: usize, hi: usize }; - /// The single spelling rule used by pointer and keyboard expansion. - /// Spaces inside an explicit @`command run` belong to that run; every - /// other separator is no operand. In particular, do not let wordBounds' - /// left scan make a blank cell borrow its neighbour. fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { if (col >= line.len) return null; const b = config.wordBounds(line, col); @@ -8585,7 +6666,7 @@ pub const Pardes = struct { /// acme: a no-drag middle/right click expands to the word under it — /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) - fn expandedSel(p: *Pardes, pane: *Pane, at: Sel) ?Sel { + fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = p.paneText(pane) catch return null; @@ -8594,10 +6675,10 @@ pub const Pardes = struct { while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const display_col: usize = @intCast(@max(0, sl.c0)); - const col = file_pane.renderedLineByteCol(pane, v, line, display_col); + const col = panes.File.renderedLineByteCol(pane, v, line, display_col); const b = expandedWord(line, col) orelse return null; - sl.c0 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.lo)); - sl.c1 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.hi) - 1); + sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); + sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); return sl; } return null; @@ -8605,16 +6686,16 @@ pub const Pardes = struct { /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) - fn cursorWordSel(p: *Pardes, pane: *Pane) Sel { + fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); const vcol = if (pane.file != null) - file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) + panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { - const pl = p.paneCursorLines(pane) catch break :blk pane.cur_col; - const local = pane.cur_row - pl.row0; - if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) break :blk pane.cur_col; - break :blk file_pane.lineDisplayOffset(pl.lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); + const lines = p.paneCursorLines(pane) catch break :blk pane.cur_col; + const local = pane.cur_row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; + break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); }; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } @@ -8628,30 +6709,14 @@ pub const Pardes = struct { return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } - /// Run one acme chord (`Look`/`Exec`) once per EXPLICIT selection, in - /// document order. False when there is only the primary range, which is - /// the caller's cue to take its own single-selection path unchanged. - /// - /// The bytes of every range are copied BEFORE the first builtin runs, for - /// the reason `submitPipe` copies too: a `Look` opens panes and an `Exec` - /// can run a builtin that edits or closes the very pane these offsets are - /// into. After that the loop owns nothing of the pane but its slot, and - /// re-checks even that. - /// - /// FOCUS FOLLOWS THE PRIMARY, not the last range. `lookAt` sets `p.active` - /// for every target it opens, so without this the pane you end up looking - /// at is whichever selection happened to sort last — an accident rather - /// than an answer. `Exec` moves focus for neither, so this costs it - /// nothing. fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; - const pl = p.paneCursorLines(pane) catch return false; - const text = p.flatSurface(pane, pl) catch return false; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, pl.row0, &ranges); + const text = p.flatSurface(pane) catch return false; + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, 0, &ranges); if (got.n < 2) return false; - var texts: [MAX_SELS][]u8 = undefined; + var texts: [Pane.max_selections][]u8 = undefined; var made: usize = 0; defer for (texts[0..made]) |t| p.gpa.free(t); for (ranges[0..got.n]) |range| { @@ -8675,9 +6740,6 @@ pub const Pardes = struct { for (texts[0..made], 0..) |txt, i| { p.runBuiltin(cmd, id, "", txt); if (i == got.pri) primary_active = p.active; - // The pane this loop is standing on can be closed by what it just - // ran — a selection whose text is `Del` is a legal Exec. Same - // slot-and-serial re-check `replaySels` makes for the same reason. const still = p.panes[id] orelse break; if (still.serial != serial) break; } @@ -8694,19 +6756,16 @@ pub const Pardes = struct { /// Exactly what a no-drag middle/right click will dispatch. text: ?[]const u8 = null, /// What hover paints. Null names the pane's live modal selection. - preview: ?Sel = null, + preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. file_word: ?FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. - expanded: ?Sel = null, + expanded: ?Pane.Sel = null, }; - /// Resolve a no-drag pointer gesture without mutating Pane. Click release - /// and delayed Look hover call this same production primitive, so existing - /// selection precedence and word expansion cannot drift into two policies. - fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Sel) PointerOperand { + fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const row = wrapped.line; @@ -8715,7 +6774,7 @@ pub const Pardes = struct { pane, wrapped.line, wrapped.at, - clicked.c0 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), + clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ) else clicked.c0; @@ -8742,7 +6801,7 @@ pub const Pardes = struct { return result; } if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { - const line = file_pane.sourceLine(pane, row); + const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = expandedWord(line, source_col) orelse return result; const lo = std.math.cast(i32, b.lo) orelse return result; @@ -8750,18 +6809,14 @@ pub const Pardes = struct { result.text = line[b.lo..b.hi]; result.file_word = .{ .row = row, .lo = lo, .hi = hi }; - // The transient middle/right slot still carries screen endpoints, - // but no text is reconstructed from this rectangular legacy - // shape. The logical span above is authoritative for dispatch and - // hover painting. var expanded = clicked; const first = pane.wrapRow(row, lo); const last = pane.wrapRow(row, hi - 1); if (first.row >= 0 and last.row >= 0) { expanded.r0 = first.row + @as(i32, BOX_H); - expanded.c0 = @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, row, first.at, lo); + expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); - expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.displayEndOffset(pane, row, last.at, hi - 1); + expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; result.expanded = expanded; @@ -8776,13 +6831,6 @@ pub const Pardes = struct { return result; } - /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — - /// the chord argument is whatever is selected, in any window), searched - /// `first` (the pane the execute happened in), then the active pane (making - /// a selection focuses its pane, so it holds the most recent one), then - /// slot order. Per pane a kept left selection wins, else an explicit modal - /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller - /// that keeps it (the 2-1 chord) copies. fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { var k: usize = 0; while (k < p.panes.len + 2) : (k += 1) { @@ -8806,14 +6854,6 @@ pub const Pardes = struct { return null; } - /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 - /// chord means "run this WITH that", and that is a command LINE: `Grep` - /// plus a held `foo` is the same string `Grep foo` you could have typed, - /// so it goes down the one path that already knows how to split a name - /// from its tail. The alternative — a second argument threaded past the - /// dispatcher — is what used to be here, and it could not survive Exec - /// becoming an ordinary builtin with one argument slot like every other. - /// Scratch-owned; `txt` itself when there is nothing to splice. fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); if (a.len == 0) return txt; @@ -8822,21 +6862,9 @@ pub const Pardes = struct { } fn handleKey(p: *Pardes, key: Key) void { - // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is - // its own one-line normal mode and owns every key until Esc or a chord. - // Before the pane lookup because it needs no pane — that is the point. if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; - // a SPC leader in flight swallows the next key, whatever it is — - // before Ctrl-w, so a modified key abandons the sequence instead of - // arming a second prefix on top of it if (p.leader_on) return p.leaderKey(key); - // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l or the - // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME - // table a tagline's own h/j/k/l reads. It stays despite the leader - // covering it because it reaches one place the leader cannot: a pane - // in raw tty mode never sees SPC (the shell owns every printable key), - // so this is the only keyboard way out of one. if (p.ctrl_w_pending) { p.ctrl_w_pending = false; for (config.window_keys) |wk| { @@ -8856,46 +6884,31 @@ pub const Pardes = struct { const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = p.splitParent(p.active); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, p.active); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; return; } - // the jump chords, global for the same reason: Ctrl-o has to get you - // out of wherever you are, including a pane in raw tty mode. Above the - // acme chords below, which is what makes Ctrl-i reachable at all — on - // a kitty-protocol host it arrives as its own key, and where it does - // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. for (config.jump_keys) |jk| { if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { - const f = p.layoutFindTerm(p.active).?; + const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { - _ = p.layoutSplitColumn(p.active, p.active, false); + _ = layout.splitColumn(p, p.active, p.active, false); } return; } - // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and - // out (terminals only); tty is deliberately off the normal editing - // path. Shift-Esc needs a host that reports modifiers on Escape (the - // kitty keyboard protocol); where it doesn't it arrives as a plain - // Escape and still means what Escape always means. const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { - // Shift-Esc IN tty is what Escape is in normal mode: Last, - // the pane you were in before this one. The pane keeps its tty - // mode, so coming back lands you in the program you left rather - // than in normal mode on top of it — and Ctrl- is still how you leave tty in place. if (tty_alt) return p.runBuiltin(.Last, p.active, "", null); pane.mode = .normal; - pane.pending = 0; - } else term_pane.enterTty(p, p.active); + pane.normal.clear(); + } else panes.Terminal.enterTty(p, p.active); return; } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. @@ -8907,75 +6920,42 @@ pub const Pardes = struct { if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); } - // `|` owns the same visible one-line tag input as search, but Enter - // snapshots an asynchronous shell filter. Escape is a pure cancel: - // restore the old tail and never emit a request. - if (pane.hasPipePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) p.submitPipe(p.active); - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); - exitTagEdit(pane); - pane.mode = .normal; - pane.pending = 0; - return; - } - // a save input in flight (scratch or terminal): Enter writes the path, - // Esc abandons; both drop the prompt text and return to the body. - if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) - p.submitSave(p.active) - else - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); - exitTagEdit(pane); - pane.mode = .normal; - pane.pending = 0; - return; - } - // a search input in flight (`/` or Find): Enter searches, Esc abandons; - // both restore the tag tail and hand focus back to the body. - if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { - const prompt_at = pane.promptAt().?; - if (hit(key, config.search_submit)) - p.submitSearch(p.active) - else if (selRegexArmed(pane)) |_| - p.applySelRegex(pane, "", false); - pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); + if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { + const submit = hit(key, config.search_submit); + switch (pane.prompt) { + .pipe => |pipe| { + if (submit) p.submitPipe(p.active); + pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); + }, + .save => |at| { + if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .search => |at| { + if (submit) + p.submitSearch(p.active) + else if (selRegexArmed(pane)) |_| + p.applySelRegex(pane, "", false); + pane.tag_tail_len = @min(at, pane.tag_tail_len); + }, + .none => unreachable, + } exitTagEdit(pane); pane.mode = .normal; - pane.pending = 0; + pane.normal.clear(); return; } - // tag editing intercepts every other key: a modal one-line editor over - // the tail, sharing the pane's mode. Above the body chords — a focused - // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); - // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that - // live preview is what makes it interactive. Through the slot, not - // `pane` — a tag chord above can run a builtin that closed it. const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } - // the acme chords in the body — look at / execute (config.look_key and - // exec_key, Enter and Tab by default) the EXPLICIT modal selection - // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back - // to the file-ish word under the cursor. if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; p.pinPaneCursor(pane); - const explicit = (p.native_images and hasPdfSelection(pane)) or + const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { - // ONE ACTION PER SELECTION. Both chords used to read the - // PRIMARY range and drop the other cursors on the floor, which - // is the one thing a multi-cursor editor must not do with a - // command the user aimed at every cursor. `chordEachSel` is - // the `submitPipe` shape — `paneRanges` once, forward, copies - // taken before anything runs — and returns false when there is - // nothing multi about this keystroke, which is every keystroke - // with one cursor and therefore the unchanged path below. if (p.chordEachSel(pane, cmd)) return; if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; @@ -8990,10 +6970,7 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } - // PDFs share BODY-NORMAL recognition with text, then deliberately - // adapt only navigation and the cross-pane command/search actions. - // Returning here keeps unsupported edits away from placeholder cells. - if (pane.mode == .normal and hasPdf(pane)) return p.handlePdfNormal(pane, key); + if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); @@ -9002,40 +6979,23 @@ pub const Pardes = struct { if (hit(key, config.escape)) { pane.mode = .normal; pane.msel.active = false; - pane.pending = 0; - // leaving an `a` append session: the cursor backs up one - // grapheme and the appended-over span becomes the - // implicit selection (helix doc.restore_cursor) + pane.normal.clear(); if (pane.append_at) |aa| { pane.append_at = null; - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); - const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - // helix restore_cursor is Range::new(from, prev(to)) on - // the GAP range, so it can never walk back past the - // append origin — a session whose edits ate everything - // typed collapses ONTO it. Without the clamp the cursor - // lands one cell before where the append began (`la`, - // Backspace, Esc). + const text = p.flatSurface(pane) catch return; + const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.hxPos(text, back); + const bc = modal.positionAt(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); - // The other cursors move the same way the primary did — - // every one of them was handed the same keys, so every - // session shrank by the same grapheme — but they - // COLLAPSE rather than span: only the primary remembers - // where its append began (append_at is one field). - // ponytail: helix restores every range's appended-over - // span; store an origin per SelRange if that matters. for (pane.sels[0..pane.nsel]) |*s| { - const sgap = modal.hxOff(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); + const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); - const bp = modal.hxPos(text, b2); + const bp = modal.positionAt(text, b2); s.row = @intCast(bp.row); s.col = @intCast(bp.col); s.arow = s.row; @@ -9046,23 +7006,16 @@ pub const Pardes = struct { } p.handleInsert(pane, key); }, - .tty => term_pane.forwardKey(p, p.active, key), + .tty => panes.Terminal.forwardKey(p, p.active, key), } } - /// A key after SPC: walk the leader tree (leader_rows, the comptime table). - /// `?` at any depth opens Help scoped to the path typed so far; an exact - /// path runs its builtin with no arguments; a key that only extends a - /// group keeps waiting. ANYTHING else abandons the sequence — a typo must - /// not leave the next keystroke armed at a builtin that closes panes, and - /// the indicator vanishing is the receipt (vim and helix drop unmapped - /// leader keys the same way). Esc lands here as one of those. fn leaderKey(p: *Pardes, key: Key) void { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); if (c == config.leader_help) { - output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| + panes.Output.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| p.reportError(p.active, "help", err); return; } @@ -9083,190 +7036,30 @@ pub const Pardes = struct { } } - /// move focus to the nearest pane in `dir` of `from` (overlap-aware - /// nearest edge). `from` is the pane the builtin ran on, which is the - /// active one for a key but the CLICKED one for a name executed in a tag. - pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { - const a = p.rects[from]; - var best: ?usize = null; - var best_d: i32 = 0; - for (p.panes, 0..) |slot, i| { - if (slot == null or i == from) continue; - const r = p.rects[i]; - const vov = a.y < r.y + r.h and r.y < a.y + a.h; - const hov = a.x < r.x + r.w and r.x < a.x + a.w; - const ok = switch (dir) { - .left => r.x + r.w <= a.x and vov, - .right => r.x >= a.x + a.w and vov, - .up => r.y + r.h <= a.y and hov, - .down => r.y >= a.y + a.h and hov, - }; - if (!ok) continue; - const d: i32 = switch (dir) { - .left => @as(i32, a.x) - @as(i32, r.x + r.w), - .right => @as(i32, r.x) - @as(i32, a.x + a.w), - .up => @as(i32, a.y) - @as(i32, r.y + r.h), - .down => @as(i32, r.y) - @as(i32, a.y + a.h), - }; - if (best == null or d < best_d) { - best = i; - best_d = d; - } - } - if (best) |b| { - p.active = b; - // a count typed before the hop was meant for the pane you left - p.panes[b].?.pending = 0; - } - } - - // ---- move-drag placement ---- - - const MovePlacement = struct { - preview_col: usize, - above_id: usize, - row: u16, - above_y: u16, - above_h: u16, - }; + // ---- helix-modal normal mode ---- - fn targetColumn(p: *Pardes, cur_x: u16) usize { - var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; - for (0..p.ncol) |c| { - if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { - tc = c; - break; - } + pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f); + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); + unreachable; } - return tc; + return panes.Terminal.cursorLines(p, pane); } - fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { - if (h < 2) return null; - const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; - const lo = y +| min_each; - const hi = y + h - min_each; - if (lo > hi) return y + h / 2; - return std.math.clamp(cur_y, lo, hi); - } - - fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { - const src = p.layoutFindTerm(id) orelse return null; - const tc = p.targetColumn(cur_x); - if (tc == src.col and p.col_n[src.col] == 1) return null; - if (tc == src.col) { - const sr = p.rects[id]; - if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; - } - var heights: [MAX_PANES]u16 = @splat(0); - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - heights[pid] = p.rects[pid].h; - } - } - if (p.col_n[src.col] > 1) { - const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1]; - heights[sib] +|= p.rects[id].h; - } - var y: u16 = TOPBAR_H; - var last: ?MovePlacement = null; - for (0..p.col_n[tc]) |k| { - const pid = p.col_terms[tc][k]; - if (pid == id) continue; - const h = heights[pid]; - const row = splitRowForExtent(y, h, cur_y) orelse { - y +|= h; - continue; - }; - const placement: MovePlacement = .{ - .preview_col = tc, - .above_id = pid, - .row = row, - .above_y = y, - .above_h = h, - }; - last = placement; - if (cur_y < y + h) return placement; - y +|= h; - } - return last; - } - - /// drop pane `id` below the pane under the cursor, converting on-screen - /// heights to weights so ONLY the split pane changes size - fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { - const placement = p.movePlacement(id, cur_x, cur_y) orelse return; - const src = p.layoutFindTerm(id) orelse return; - const source_multi = p.col_n[src.col] > 1; - var heights: [MAX_PANES]u16 = @splat(0); - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - heights[pid] = p.rects[pid].h; - } - } - p.layoutRemove(id); - if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { - const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx]; - heights[sib] +|= p.rects[id].h; - } - const af = p.layoutFindTerm(placement.above_id) orelse return; - const upper_h = @max(1, placement.row -| placement.above_y); - const lower_h = @max(1, placement.above_h -| upper_h); - heights[placement.above_id] = upper_h; - heights[id] = lower_h; - p.layoutInsert(af.col, af.idx + 1, id); - p.setColumnWeights(af.col, &heights); - if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights); - } - - fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { - if (col >= p.ncol) return; - for (0..p.col_n[col]) |k| { - const pid = p.col_terms[col][k]; - if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); - } - } - - // ---- helix-modal normal mode ---- - - const PaneLines = struct { - lines: []const []const u8, - row0: i32, // absolute row of lines[0] - }; - - /// The lines the cursor moves over, absolute rows. File: all content lines. - /// Terminal: the whole history+active grid with the edit buffer's lines - /// standing in for the rows it covers, so motions ride the scrollback and - /// the typed text alike. Scratch-arena backed. - /// (pub only for test/hxdiff.zig — the helix differential harness dumps - /// this surface as a tty case's final text.) - pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { - const arena = p.scratch.allocator(); - if (pane.file) |*f| return .{ .lines = try file_pane.cursorLines(arena, pane, f), .row0 = 0 }; - if (hasPdf(pane)) { - if (comptime pdf_enabled) return .{ - .lines = try pdf_pane.textLines(&pane.pdf.?, p.pdf_gpa, arena), - .row0 = 0, - }; - unreachable; - } - return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 }; - } - - fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { - if (pane.file != null) - return file_pane.byteAtRowDisplay(pane, row, from_raw, display_col); - const pl = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); - const local = row - pl.row0; - if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) - return @max(0, from_raw + display_col); - return @intCast(file_pane.byteAtDisplayFrom( - pl.lines[@intCast(local)], - @intCast(@max(0, from_raw)), - @intCast(@max(0, display_col)), - )); + fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { + if (pane.file != null) + return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col); + const lines = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); + const local = row; + if (local < 0 or @as(usize, @intCast(local)) >= lines.len) + return @max(0, from_raw + display_col); + return @intCast(panes.File.byteAtDisplayFrom( + lines[@intCast(local)], + @intCast(@max(0, from_raw)), + @intCast(@max(0, display_col)), + )); } /// Freeze the live terminal cursor into the modal coordinate space. The @@ -9274,434 +7067,29 @@ pub const Pardes = struct { fn pinPaneCursor(p: *Pardes, pane: *Pane) void { if (pane.cur_pinned) return; pane.pinCursor(); - if (pane.file == null and !hasPdf(pane)) + if (pane.file == null and !pane.hasPdf()) pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col); } - fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor { - const r: i32 = pane.cur_row - pl.row0; - return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) }; - } - - fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { - pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; - pane.cur_col = @intCast(c.col); - pane.cur_pinned = true; - } - - fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { - if (lines.len == 0) return c; - const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; - const target = lines[row]; - if (target.len == 0) return .{ .row = row, .col = 0 }; - const source = if (c.row < lines.len) lines[c.row] else ""; - const goal = file_pane.rawDisplayCol(source, c.col); - const mapped = file_pane.rawAtDisplay(target, goal); - const last = modal.prevGrapheme(target, target.len); - return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; - } - - // ---- helix range plumbing (see modal.zig "helix range engine") ---- - // The pane's cursor + vsel cells render ONE helix gap range over the flat - // motion surface. Every motion builds the current range, transforms it the - // way the helix command would, and writes it back: normal mode REPLACES - // the selection with the motion's range, select mode (v) extends it via - // put_cursor. The differential suite (zig build hxdiff) pins all of this - // against a real helix, key for key. - - /// the flat motion surface: file content as-is; terminals join the - /// cursor-lines dump (scratch-arena backed, same lifetime as pl) - fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { + fn flatSurface(p: *Pardes, pane: *Pane) ![]const u8 { if (pane.file) |f| return f.content; - if (hasPdf(pane)) { + if (pane.hasPdf()) { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } - return term_pane.flatSurface(p, pane, pl.lines); - } - - fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize { - if (pane.file != null) return file_pane.textOffset(pane, text, c); - return modal.hxOff(text, c); - } - - fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize { - if (pane.file != null) return file_pane.textLineStart(pane, text, row); - return modal.lineStartOffset(text, row); - } - - fn paneLineCount(pane: *Pane, text: []const u8) usize { - if (pane.file != null) return file_pane.textLineCount(pane, text); - return modal.hxLineCount(text); - } - - fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor { - if (pane.file != null) return file_pane.textPosition(pane, text, off); - return modal.hxPos(text, off); - } - - /// the current selection as a helix gap range over `text`, whose first - /// line is absolute row `row0` (0 for the motion surface and for file - /// content; a terminal's edit buffer starts wherever it was anchored) - fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { - const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); - if (pane.msel.active) { - // legacy line selection (file-search results highlight): linewise - const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); - const s = modal.lineStartOffset(text, r0); - const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); - return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; - } - if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); - return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; - } - - /// a gap range from its two block-cursor CELLS — the arithmetic paneRange - /// does for cur/vsel, shared with the extra selections, which are stored - /// in exactly the same shape - fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange { - const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); - const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); - return cellOffRange(text, a, c); - } - - /// the same, from the two cells' gap offsets - fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange { - if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; - return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; - } - - /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare - /// 1-wide cursor). setPaneRange's own conversion, factored out so the - /// extra selections write back through the same three lines. - fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } { - if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; - if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; - return .{ .cur = r.head, .anc = r.head }; - } - - /// write a helix range back into pane state. `explicit` marks user-intent - /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords - /// act only on those; motion residue stays implicit. - fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { - var r = r0; - if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 - const off = rangeCells(text, r); - const cc = panePos(pane, text, off.cur); - // a bare block cursor has both cells on the same offset — the common - // case by far — and this conversion is not free even indexed - const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc); - pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; - pane.cur_col = @intCast(cc.col); - pane.vsel = .{ - .active = off.anc != off.cur or pane.select, - .row = @as(i32, @intCast(ac.row)) + pl.row0, - .col = @intCast(ac.col), - .explicit = explicit or pane.select, - }; - pane.msel.active = false; - pane.nsel = 0; // writing ONE range means the selection IS that range - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.pending = 0; - pane.ensureCursorVisible(); - } - - // ---- the OTHER selections (helix Selection.ranges / primary_index) ---- - // Two functions read and write the whole list; everything else in this - // file still speaks the single primary range, and replaySels below is what - // makes an ordinary key act at every cursor. - - /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER - /// (pane.sels is kept that way, so this only has to slot the primary in). - /// Returns how many were written and which index is the primary. - fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } { - const pr = paneRange(pane, text, row0); - var n: usize = 0; - var pri: usize = 0; - var placed = false; - for (pane.sels[0..pane.nsel]) |s| { - const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); - if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { - pri = n; - out[n] = pr; - n += 1; - placed = true; - } - out[n] = r; - n += 1; - } - if (!placed) { - pri = n; - out[n] = pr; - n += 1; - } - return .{ .n = n, .pri = pri }; - } - - /// Write a whole selection back — helix's `Selection::new`: min-width-1, - /// sorted by start, overlapping ranges merged (the primary following its - /// range through a merge). `ranges[pri]` lands in the primary slot through - /// setPaneRange, so nothing downstream can tell it apart from a lone - /// cursor; the rest become pane.sels. `sticky` carries each range's own - /// j/k goal column, -1 for the ones that have none. - fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void { - if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" - var r: [MAX_SELS]modal.HxRange = undefined; - var st: [MAX_SELS]i32 = undefined; - var n: usize = @min(in.len, MAX_SELS); - var pri: usize = @min(pri0, n - 1); - for (in[0..n], 0..) |x, i| { - r[i] = x; - if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); - st[i] = if (i < sticky.len) sticky[i] else -1; - } - // insertion sort by start — n is tiny and usually already ordered - var i: usize = 1; - while (i < n) : (i += 1) { - var j = i; - while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { - std.mem.swap(modal.HxRange, &r[j], &r[j - 1]); - std.mem.swap(i32, &st[j], &st[j - 1]); - if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; - } - } - // merge overlaps (helix Range::overlaps + Range::merge, kept forward: - // a merged range takes the union and loses its direction only when the - // two disagree, which is what helix's else-branch does) - var k: usize = 0; - i = 1; - while (i < n) : (i += 1) { - const a = r[k]; - const b = r[i]; - const af = @min(a.anchor, a.head); - const at = @max(a.anchor, a.head); - const bf = @min(b.anchor, b.head); - const bt = @max(b.anchor, b.head); - if (af == bf or (at > bf and bt > af)) { - r[k] = if (a.anchor > a.head and b.anchor > b.head) - .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } - else - .{ .anchor = @min(af, bf), .head = @max(at, bt) }; - if (pri == i) pri = k; - if (st[k] < 0) st[k] = st[i]; - continue; - } - k += 1; - r[k] = b; - st[k] = st[i]; - if (pri == i) pri = k; - } - n = k + 1; - setPaneRange(pane, pl, text, r[pri], explicit); - pane.sticky_col = st[pri]; - var w: usize = 0; - for (r[0..n], 0..) |x, idx| { - if (idx == pri) continue; - const c = rangeCells(text, x); - const cc = modal.hxPos(text, c.cur); - const ac = modal.hxPos(text, c.anc); - pane.sels[w] = .{ - .row = @as(i32, @intCast(cc.row)) + pl.row0, - .col = @intCast(cc.col), - .arow = @as(i32, @intCast(ac.row)) + pl.row0, - .acol = @intCast(ac.col), - .sticky = st[idx], - }; - w += 1; - } - pane.nsel = @intCast(w); - } - - /// The helix keys that act on the selection LIST rather than on the text. - /// Each reads the whole list and writes a whole list back; none is a - /// motion, which is why Action.scope marks them exempt from per-range - /// replay. The goal columns are dropped on the way through — every - /// one of these is a fresh intent about WHERE the cursors are, the same - /// reason setPaneRange resets sticky_col. - fn multiSelAction(pane: *Pane, pl: PaneLines, text: []const u8, kind: normal_input.Multi, cnt: usize) void { - var rs: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, pl.row0, &rs); - const n = got.n; - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - if (kind == .remove_primary) { - if (n < 2) return; // helix: "no selections remaining" - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n], 0..) |r, i| { - if (i == got.pri) continue; - out[m] = r; - m += 1; - } - // helix Selection::remove: the NEXT range takes over, or the - // previous one when the primary was last - return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); - } - if (kind == .rotate_forward or kind == .rotate_backward) { - const step = cnt % n; - const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; - return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); - } - if (kind == .merge) { - // helix merge_selections: first.merge(last) — the ranges are - // sorted, so that is simply the whole span - const lo = @min(rs[0].anchor, rs[0].head); - const hi = @max(rs[n - 1].anchor, rs[n - 1].head); - const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; - const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; - return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); - } - if (kind == .merge_consecutive) { - // ranges that TOUCH become one; setPaneRanges already merges the - // ones that overlap, so widening each by a grapheme says exactly - // "consecutive counts as overlapping" and nothing else - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - var pri: usize = 0; - for (rs[0..n], 0..) |r, i| { - if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { - const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); - const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); - out[m - 1] = .{ .anchor = lo, .head = hi }; - if (i == got.pri) pri = m - 1; - continue; - } - if (i == got.pri) pri = m; - out[m] = r; - m += 1; - } - return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); - } - if (kind == .split_newline) { - // helix selection::split_on_newline — one range per line the - // selection covers, the newlines themselves left out - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n]) |r| { - const from = @min(r.anchor, r.head); - const to = @max(r.anchor, r.head); - if (from == to) { - if (m < MAX_SELS) { - out[m] = r; - m += 1; - } - continue; - } - var start = from; - while (start < to and m < MAX_SELS) { - const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start)); - if (eol >= to) { - out[m] = .{ .anchor = start, .head = to }; - m += 1; - break; - } - out[m] = .{ .anchor = start, .head = eol }; - m += 1; - start = eol + 1; - } - } - if (m == 0) return; - return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 - } - if (kind == .trim) { - // helix trim_selections: whitespace off both ends; ranges that are - // empty or all whitespace are dropped entirely - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - for (rs[0..n]) |r| { - var from = @min(r.anchor, r.head); - var to = @max(r.anchor, r.head); - while (from < to and std.ascii.isWhitespace(text[from])) from += 1; - while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; - if (from >= to) continue; - out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; - m += 1; - } - if (m == 0) { // helix: collapse_selection + keep_primary_selection - const c = modal.hxCursor(text, rs[got.pri]); - return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false); - } - // helix: the first survivor that OVERLAPS the old primary, else the last - const pf = @min(rs[got.pri].anchor, rs[got.pri].head); - const pt = @max(rs[got.pri].anchor, rs[got.pri].head); - var pri = m - 1; - for (out[0..m], 0..) |r, i| { - const f = @min(r.anchor, r.head); - const t = @max(r.anchor, r.head); - if (f == pf or (t > pf and pt > f)) { - pri = i; - break; - } - } - return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); - } - // C / Alt-C — helix copy_selection_on_line, a copy of each range on the - // next/previous line that is long enough to hold its columns. - // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as - // one column here. Everything else in this file measures the same way - // (hscroll, the mouse, the renderer's gutter), and fixing it means - // teaching all of them tab stops at once. - const below = kind == .copy_below; - var out: [MAX_SELS]modal.HxRange = undefined; - var m: usize = 0; - var pri: usize = 0; - const nlines = modal.hxLineCount(text); - for (rs[0..n], 0..) |r, ri| { - const is_pri = ri == got.pri; - // head-exclusive: back the leading end off onto its own cell - const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); - const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); - const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; - if (m >= MAX_SELS) break; - if (is_pri) pri = m; - out[m] = r; - m += 1; - var made: usize = 0; - var k: usize = 0; - while (made < cnt and m < MAX_SELS) : (k += 1) { - const d = (k + 1) * height; - const arow = if (below) ap.row + d else ap.row -| d; - const hrow = if (below) hp.row + d else hp.row -| d; - if (arow >= nlines or hrow >= nlines) break; - const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col }); - const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col }); - // a line too short to reach the column is skipped, not clamped - if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) { - if (is_pri) pri = m; - out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); - m += 1; - made += 1; - } - if (arow == 0 and hrow == 0) break; - } - } - setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); + const lines = try panes.Terminal.cursorLines(p, pane); + return panes.Terminal.flatSurface(p, pane, lines); } - // ---- `s` / `S`: the selection LIST from a regex ---- - // The other two list-making keys, and the only ones that need a pattern - // typed first. They reuse the `/` input wholesale (startSearch — the tag - // tail IS the prompt) and differ from it in one thing: the pattern is - // re-applied on every keystroke, so the selection is the preview. - - /// helix `s` / `S`: arm the tag input for a regex, remembering the - /// selection it is about to rewrite. Nothing moves yet — every keystroke - /// below re-derives the preview from this snapshot, and Enter simply stops - /// while Esc puts the snapshot back. fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const got = paneRanges(pane, text, pl.row0, &pane.sel_snap); + const text = p.flatSurface(pane) catch return; + const got = pane.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - p.startSearch(pane, if (split) config.split_marker else config.select_marker); + p.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker }); } - /// The pattern an armed `s`/`S` input holds right now, and which of the two - /// it is — read back off the MARKER, exactly the way submitSearch decides - /// which search is running. Null for `/`, Find, Grep and Rename. fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { const prompt_at = switch (pane.prompt) { .search => |at| at, @@ -9716,37 +7104,12 @@ pub const Pardes = struct { return .{ .pat = armed[slash + 1 ..], .split = split }; } - /// Put the selection back the way `s`/`S` found it and then, if `pat` - /// compiles and hits, rewrite it: helix's select_on_matches (every match - /// INSIDE each range becomes a range) and split_on_matches (each range - /// becomes the pieces BETWEEN its matches). - /// - /// Anything that yields nothing — an empty pattern, one that will not - /// compile, one that does not match — leaves the snapshot standing, which - /// is helix's "nothing selected" and also what makes typing a pattern one - /// character at a time bearable: every prefix of it is one of those. - /// - /// ponytail: MAX_SELS ranges, and matches past that are dropped rather - /// than growing the list — the ceiling the whole selection model has. - /// ponytail: mvzr searches from each match's end, so `^` and `$` assert - /// against THAT position rather than against a line the way helix's - /// multi_line regex does, and `.` matches a newline like any other byte. - /// Both are waived. `[^\n]` LOOKS like the workaround for the second and - /// must not be suggested as one: the live preview compiles every prefix, - /// and the prefix `[^\` panics mvzr (index out of bounds in parseCharSet, - /// mvzr.zig valueFor) before the pattern can ever be finished. Guarding - /// the compile is what would make the advice sayable. fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; + const text = p.flatSurface(pane) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; - var out: [MAX_SELS]modal.HxRange = undefined; + var out: [Pane.max_selections]modal.Selection = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { - // helix smart-case: a pattern with no uppercase in it matches - // case-blind. mvzr has no such flag — "lowercase your string" is - // its own advice — and ASCII folding is byte for byte, so a - // lowercased copy of the surface has exactly the same offsets. var hay_all = text; if (for (pat) |c| { if (std.ascii.isUpper(c)) break false; @@ -9762,23 +7125,20 @@ pub const Pardes = struct { const hay = hay_all[from..to]; var at: usize = 0; var piece = from; // split: where the next piece begins - while (at < hay.len and m < MAX_SELS) { + while (at < hay.len and m < Pane.max_selections) { const hit_at = re.matchPos(at, hay) orelse break; if (split) { out[m] = .{ .anchor = piece, .head = from + hit_at.start }; m += 1; piece = from + hit_at.end; } else if (from + hit_at.start != to) { - // a match sitting right off the END of the range is - // dropped (helix: what `\b` and empty matches produce - // there), everything else becomes a range out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end }; m += 1; } // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } - if (split and piece < to and m < MAX_SELS) { + if (split and piece < to and m < Pane.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -9791,95 +7151,15 @@ pub const Pardes = struct { r.anchor = @min(r.anchor, text.len); r.head = @min(r.head, text.len); } - return setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); + return pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } - setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) - } - - /// Pane keeps compact codepoints for dump/layout stability; this pair is - /// the only bridge to the parser's typed state. Recognition never reads - /// these representation fields directly. - fn paneNormalState(pane: *const Pane) normal_input.State { - const prefix: normal_input.Prefix = if (pane.pending == config.goto_prefix) - .goto - else if (pane.pending == config.view_prefix) - .view - else if (pane.pending == config.match_prefix) - .match - else if (pane.pending == config.find_char_fwd) - .find_fwd - else if (pane.pending == config.find_char_back) - .find_back - else if (pane.pending == config.till_char_fwd) - .till_fwd - else if (pane.pending == config.till_char_back) - .till_back - else if (pane.pending == config.replace_prefix) - .replace - else if (pane.pending == config.next_prefix) - .next - else if (pane.pending == config.prev_prefix) - .prev - else - .none; - const match_sub: normal_input.MatchSub = if (pane.pending2 == config.match_inside) - .inside - else if (pane.pending2 == config.match_around) - .around - else if (pane.pending2 == config.surround_add) - .surround_add - else if (pane.pending2 == config.surround_replace) - .surround_replace - else if (pane.pending2 == config.surround_delete) - .surround_delete - else - .none; - return .{ - .count = pane.count, - .prefix = prefix, - .match_sub = match_sub, - .held_char = pane.pending_ch, - }; - } - - fn putPaneNormalState(pane: *Pane, state: normal_input.State) void { - pane.count = state.count; - pane.pending = switch (state.prefix) { - .none => 0, - .goto => config.goto_prefix, - .view => config.view_prefix, - .match => config.match_prefix, - .find_fwd => config.find_char_fwd, - .find_back => config.find_char_back, - .till_fwd => config.till_char_fwd, - .till_back => config.till_char_back, - .replace => config.replace_prefix, - .next => config.next_prefix, - .prev => config.prev_prefix, - }; - pane.pending2 = switch (state.match_sub) { - .none => 0, - .inside => config.match_inside, - .around => config.match_around, - .surround_add => config.surround_add, - .surround_replace => config.surround_replace, - .surround_delete => config.surround_delete, - }; - pane.pending_ch = state.held_char; + pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } - /// Everything one keystroke may CONSUME on the way through the modal - /// handler. A key means the same thing at every cursor, so the replay puts - /// all of it back before each pass and keeps whatever the PRIMARY's pass - /// left. (sticky_col is deliberately absent: it is per-range, and rides - /// along in SelRange.sticky instead.) const KeyState = struct { - mode: Mode, + mode: Pane.Mode, select: bool, - count: u32, - pending: u21, - pending2: u21, - pending_ch: u21, + normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), @@ -9888,10 +7168,7 @@ pub const Pardes = struct { return .{ .mode = pane.mode, .select = pane.select, - .count = pane.count, - .pending = pane.pending, - .pending2 = pane.pending2, - .pending_ch = pane.pending_ch, + .normal = pane.normal, .find_op = pane.find_op, .find_ch = pane.find_ch, .append_at = pane.append_at, @@ -9901,10 +7178,7 @@ pub const Pardes = struct { fn into(s: KeyState, pane: *Pane) void { pane.mode = s.mode; pane.select = s.select; - pane.count = s.count; - pane.pending = s.pending; - pane.pending2 = s.pending2; - pane.pending_ch = s.pending_ch; + pane.normal = s.normal; pane.find_op = s.find_op; pane.find_ch = s.find_ch; pane.append_at = s.append_at; @@ -9913,41 +7187,29 @@ pub const Pardes = struct { /// what a replayed key does at each cursor const Replay = union(enum) { - normal: normal_input.Action, + normal: modal.Normal.Action, insert: Key, }; - /// Run one keystroke at EVERY cursor, by replaying the single-selection - /// handler once per range. This IS the multiple-cursor mechanism, and it - /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and - /// the handler underneath is the same code the 800 differential cases pin. - /// - /// Two rules make the replay legal without helix's change-mapping: - /// * ranges are visited LAST FIRST, so an edit never disturbs the - /// row/col of a range still waiting its turn — everything it touches - /// is below. - /// * a finished pass's result is recorded as a distance from the END of - /// the surface, which an edit strictly before it cannot change (the - /// text and the position shift by exactly the same amount). fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void { const id = p.active; const serial = pane.serial; // the whole selection in pane coordinates, document order. pane.sels // is already ordered, so this only slots the primary into place. - var list: [MAX_SELS]SelRange = undefined; + var list: [Pane.max_selections]Pane.SelRange = undefined; var n: usize = 0; var pri: usize = 0; - const prim: SelRange = .{ + const prim: Pane.SelRange = .{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, .sticky = pane.sticky_col, }; - const pr = selStart(prim); + const pr = Pane.selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { - const sr = selStart(s); + const sr = Pane.selStart(s); if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { pri = n; list[n] = prim; @@ -9969,7 +7231,7 @@ pub const Pardes = struct { var after_expl = explicit; // each pass's result: cursor and anchor cells as distances from the // end of the surface text, plus the range's own j/k goal column - var res: [MAX_SELS]struct { cur: usize, anc: usize, sticky: i32 } = undefined; + var res: [Pane.max_selections]struct { cur: usize, anc: usize, sticky: i32 } = undefined; p.multi_on = true; p.multi_stop = false; var passes: usize = 0; @@ -9994,25 +7256,18 @@ pub const Pardes = struct { .normal => |normal_action| p.executeNormalAction(pane, normal_action), .insert => |insert_key| p.insertKey(pane, insert_key), } - // the stop check comes FIRST: the pass that set it may have freed - // this very pane (a builtin closing it), so nothing below may read - // through the pointer if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } - const pl = p.paneCursorLines(pane) catch { - p.multi_stop = true; // out of memory mid-replay: collapse, don't guess - break; - }; - const text = p.flatSurface(pane, pl) catch { + const text = p.flatSurface(pane) catch { p.multi_stop = true; break; }; - const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; @@ -10020,270 +7275,30 @@ pub const Pardes = struct { p.multi_on = false; p.multi_first = false; if (p.multi_stop) { - // the pass reached outside the buffer (a builtin, a language - // query) and may have closed or reused the pane it ran on: drop - // back to one cursor rather than replaying it n more times p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - var rs: [MAX_SELS]modal.HxRange = undefined; - var st: [MAX_SELS]i32 = undefined; + const text = p.flatSurface(pane) catch return; + var rs: [Pane.max_selections]modal.Selection = undefined; + var st: [Pane.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { - rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); + rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } - setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl); + pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } - /// a range's start CELL (document order key) — the smaller of its two ends - fn selStart(s: SelRange) struct { row: i32, col: i32 } { - if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; - return .{ .row = s.row, .col = s.col }; - } - - /// The last line a goto may land on: helix skips the empty trailing line. - /// Called from the three `g`/`G` Action branches that need it and nowhere - /// else — it used to be eager at the top of body-normal execution, so every - /// keystroke of every kind paid a full count of the buffer's newlines. - /// "Does the buffer end in a newline" is the same question as the walk to - /// the last line start that stood here, and it is one byte instead of a - /// second pass. - fn maxLine(text: []const u8) usize { - const nl = modal.hxLineCount(text); - return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; - } - - /// point-target motion: collapse there (extend in select mode) - fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); - } - - /// word motions select their traversed span (extend mode: head only) - fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { - const wr = modal.hxWordMove(text, range, cnt, target); - const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; - setPaneRange(pane, pl, text, res, false); - } - - /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) - fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { - const cur = modal.hxCursor(text, range); - const t = modal.hxFindTarget(text, cur, ch, fwd, till, cnt) orelse return; - const res = if (pane.select) - modal.hxPutCursor(text, range, t, true) - else - modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); - setPaneRange(pane, pl, text, res, false); - } - - /// j/k and friends: sticky goal column, clamped onto short lines' newline - fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { - const cur = modal.hxCursor(text, range); - const pos = panePos(pane, text, cur); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.sticky_col) - else - file_pane.rawDisplayCol(modal.lineSlice(text, pos.row), pos.col); - // modal.hxVertTarget with the row we already have and the indexed - // offset conversion — it would otherwise recount the buffer's newlines - // and walk to the target line, two more full passes per j/k - const last_row = paneLineCount(pane, text) - 1; - const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); - const target_col = file_pane.rawAtDisplay(modal.lineSlice(text, nline), goal); - const t = paneOff(pane, text, .{ .row = nline, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - /// `gj`/`gk`: one VISUAL line, following the automatic breaks a wrapped - /// body draws rather than the newlines in the file. The goal column is the - /// one INSIDE the visual row, so a run of them walks straight down a - /// paragraph; on the last visual row of a line the step crosses into the - /// next line's first row, exactly as the eye does. - /// - /// With wrapping off — Wrap unset, a terminal pane, an output pane too - /// narrow to record its map — a line is one visual row and this IS - /// verticalMove, which is why nothing upstream branches on the setting. - fn visualMove( - pane: *Pane, - pl: PaneLines, - text: []const u8, - range: modal.HxRange, - down: bool, - cnt: usize, - width: usize, - ) void { - const cur = modal.hxCursor(text, range); - const pos = panePos(pane, text, cur); - const last_row = paneLineCount(pane, text) - 1; - var row = pos.row; - var line = modal.lineSlice(text, row); - var vrow = file_pane.visualRow(line, pos.col, width); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.sticky_col) - else - file_pane.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); - var steps = @max(1, cnt); - while (steps > 0) : (steps -= 1) { - if (down) { - if (vrow.end < line.len) { - vrow = file_pane.visualRow(line, vrow.end, width); - continue; - } - if (row == last_row) break; - row += 1; - line = modal.lineSlice(text, row); - vrow = file_pane.visualRow(line, 0, width); - } else { - if (vrow.start > 0) { - vrow = file_pane.visualRow(line, vrow.start - 1, width); - continue; - } - if (row == 0) break; - row -= 1; - line = modal.lineSlice(text, row); - vrow = file_pane.visualRow(line, line.len, width); - } - } - // The newline slot is a real cursor position, but the first byte of the - // NEXT visual row is not: landing there would read as two rows moved. - var target_col = vrow.start + file_pane.rawAtDisplay(line[vrow.start..vrow.end], goal); - if (vrow.end < line.len and target_col >= vrow.end) - target_col = modal.graphemeStart(line, vrow.end - 1); - const t = paneOff(pane, text, .{ .row = row, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - /// How wide a wrapped row of this pane is, or 0 when it does not wrap. - /// Only a file-backed body wraps: a terminal's rows are the emulator's - /// own, already broken where it decided to break them. fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize { if (pane.file == null) return 0; - return file_pane.wrapWidth(pane, p.settings.wrap); - } - - /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows - fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { - const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); - pane.scrollBy(if (down) half else -half); - verticalMove(pane, pl, text, range, down, @intCast(half)); - } - - /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift - /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 - fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - pane.scrollBy(delta); - const top = pane.scroll(); - const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); - const cur = modal.hxCursor(text, range); - if (delta > 0) { - const snap: i32 = @max(0, @min(top + margin, last_row)); - const head = paneLineStart(pane, text, @intCast(snap)); - if (head <= cur) return; - const anchor = if (pane.select) range.anchor else head; - setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); - } else { - const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); - const head = paneLineStart(pane, text, @intCast(snap)); - if (head >= cur) return; - const anchor = if (pane.select) range.anchor else head; - setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); - } + return panes.File.wrapWidth(pane, p.settings.wrap); } - /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) - fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - const top = pane.scroll(); - const last_row: i32 = @intCast(paneLineCount(pane, text) - 1); - const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); - const n: i32 = @intCast(cnt - 1); - var vline: i32 = switch (which) { - .top => top + margin + n, - .center => top + @divTrunc(last_vis, 2), - .bottom => top + last_vis - (margin + n), - }; - vline = @max(vline, top + margin); - vline = @min(vline, top + last_vis - margin); - const row: i32 = std.math.clamp(vline, 0, last_row); - pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row))); - } - - /// helix Range::line_range — the inclusive line span a range covers - fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { - const from = @min(r.anchor, r.head); - const to = @max(r.anchor, r.head); - const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); - return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; - } - - fn lineStartOrEof(text: []const u8, line: usize) usize { - if (line >= modal.hxLineCount(text)) return text.len; - return modal.lineStartOffset(text, line); - } - - /// helix `x` extend_line_below: full lines incl. the newline, cursor ON - /// the last one's '\n'; an already-line-bounded selection grows downward - fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { - const span = rangeLineSpan(text, range); - const start = modal.lineStartOffset(text, span.start); - const end = lineStartOrEof(text, span.end + 1); - const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; - const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); - setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); - } - - /// helix `X` extend_to_line_bounds (direction kept) - fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { - const span = rangeLineSpan(text, range); - const start = modal.lineStartOffset(text, span.start); - const end = lineStartOrEof(text, span.end + 1); - const r: modal.HxRange = if (range.head < range.anchor) - .{ .anchor = end, .head = start } - else - .{ .anchor = start, .head = end }; - setPaneRange(pane, pl, text, r, true); - } - - /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) - fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { - const span = rangeLineSpan(text, range); - if (span.start == span.end) return; - const from = @min(range.anchor, range.head); - const to = @max(range.anchor, range.head); - var start = modal.lineStartOffset(text, span.start); - var end = lineStartOrEof(text, span.end + 1); - if (start != from) start = lineStartOrEof(text, span.start + 1); - if (end != to) end = modal.lineStartOffset(text, span.end); - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - const r: modal.HxRange = if (range.head < range.anchor) - .{ .anchor = end, .head = start } - else - .{ .anchor = start, .head = end }; - setPaneRange(pane, pl, text, r, expl); - } - - /// Keys that are about the selection LIST, or about the session rather - /// than the text: they act ONCE however many cursors there are. The - /// multi-cursor family rewrites the list wholesale, and the rest would - /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). - /// fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { - var state = paneNormalState(pane); - const parsed = normal_input.parse(&state, normalInput(key)); - putPaneNormalState(pane, state); + const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, @@ -10293,16 +7308,11 @@ pub const Pardes = struct { p.replaySels(pane, .{ .normal = semantic }); } - /// Text-pane adapter for the semantic BODY-NORMAL vocabulary. Parsing is - /// complete before this function runs; this switch reads document state - /// only to execute the already-recognized action. - fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void { + fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void { p.pinPaneCursor(pane); - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const lines = pl.lines; - const range = paneRange(pane, text, pl.row0); - const cur = modal.hxCursor(text, range); + const text = p.flatSurface(pane) catch return; + const range = pane.primaryRange(text, 0); + const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { @@ -10311,35 +7321,35 @@ pub const Pardes = struct { }, .goto => |go| switch (go.target) { .file_start => { - const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, maxLine(text)) else 0; - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0; + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, - .last_line => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))), - .line_start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))), + .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .line_end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(text, range, ls + nw); }, - .line_down => return visualMove(pane, pl, text, range, true, go.count, p.paneWrapWidth(pane)), - .line_up => return visualMove(pane, pl, text, range, false, go.count, p.paneWrapWidth(pane)), + .line_down => return pane.visualMove(text, range, true, go.count, p.paneWrapWidth(pane)), + .line_up => return pane.visualMove(text, range, false, go.count, p.paneWrapWidth(pane)), .column => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; - return pointMove(pane, pl, text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); + const slice = text[ls..modal.lineEndOffset(text, line)]; + return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, - .view_top => return gotoWindow(pane, pl, text, range, .top, go.count), - .view_center => return gotoWindow(pane, pl, text, range, .center, go.count), - .view_bottom => return gotoWindow(pane, pl, text, range, .bottom, go.count), + .view_top => return pane.gotoWindow(text, range, .top, go.count), + .view_center => return pane.gotoWindow(text, range, .center, go.count), + .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count), }, .view => |view| switch (view) { .top => { @@ -10354,8 +7364,8 @@ pub const Pardes = struct { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, - .scroll_down => return scrollViewMove(pane, pl, text, range, 1), - .scroll_up => return scrollViewMove(pane, pl, text, range, -1), + .scroll_down => return pane.scrollViewMove(text, range, 1), + .scroll_up => return pane.scrollViewMove(text, range, -1), }, .find => |find| { const op: u8 = switch (find.kind) { @@ -10366,9 +7376,7 @@ pub const Pardes = struct { }; pane.find_op = op; pane.find_ch = find.char; - return findMove( - pane, - pl, + return pane.findMove( text, range, find.char, @@ -10379,26 +7387,27 @@ pub const Pardes = struct { }, .replace_char => |char| return p.normalReplaceChar(pane, char), .match_bracket => { - const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; - return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); + const lines = p.paneCursorLines(pane) catch return; + const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; + return pane.pointMove(text, range, modal.offsetAt(text, mc)); }, - .textobject => |object| return p.textobjectSelect(pane, pl, object.char, object.around), + .textobject => |object| return p.textobjectSelect(pane, text, object.char, object.around), .surround_add => |char| return p.surroundAdd(pane, char), - .surround_delete => |char| return p.surroundDelete(pane, pl, char), - .surround_replace => |replace| return p.surroundReplace(pane, pl, replace.from, replace.to), + .surround_delete => |char| return p.surroundDelete(pane, char), + .surround_replace => |replace| return p.surroundReplace(pane, replace.from, replace.to), .paragraph => |paragraph| { - const r2 = modal.hxParaMove(text, range, paragraph.count, paragraph.direction == .forward, pane.select); - return setPaneRange(pane, pl, text, r2, false); + const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select); + return pane.setRange(text, 0, r2, false); }, .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { - if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and + if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } - if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) + if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { pane.search_row = null; @@ -10414,21 +7423,21 @@ pub const Pardes = struct { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); - return pointMove(pane, pl, text, range, target); + return pane.pointMove(text, range, target); }, - .down => return verticalMove(pane, pl, text, range, true, move.count), - .up => return verticalMove(pane, pl, text, range, false, move.count), - .next_word_start => return wordMove(pane, pl, text, range, move.count, .next_word_start), - .prev_word_start => return wordMove(pane, pl, text, range, move.count, .prev_word_start), - .next_word_end => return wordMove(pane, pl, text, range, move.count, .next_word_end), - .next_long_word_start => return wordMove(pane, pl, text, range, move.count, .next_long_word_start), - .prev_long_word_start => return wordMove(pane, pl, text, range, move.count, .prev_long_word_start), - .next_long_word_end => return wordMove(pane, pl, text, range, move.count, .next_long_word_end), + .down => return pane.verticalMove(text, range, true, move.count), + .up => return pane.verticalMove(text, range, false, move.count), + .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start), + .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start), + .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end), + .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start), + .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start), + .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (pane.find_op == 0) return; @@ -10437,45 +7446,45 @@ pub const Pardes = struct { var repeated = range; var moved = false; for (0..count) |_| { - const cc = modal.hxCursor(text, repeated); - const target = modal.hxFindTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; + const cc = modal.selectionCursor(text, repeated); + const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) - modal.hxPutCursor(text, repeated, target, true) + modal.moveSelectionCursor(text, repeated, target, true) else - modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, target, true); + modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; - return setPaneRange(pane, pl, text, repeated, false); + return pane.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { - .start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { - const line = modal.hxLineOf(text, cur); + const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); + return pane.pointMove(text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; - const line = @min(@as(usize, go.count) - 1, maxLine(text)); - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text)); + return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .page => |page| switch (page.kind) { // Counts were parsed historically but these four text view // actions intentionally move exactly one viewport unit. - .half_down => return halfPageMove(pane, pl, text, range, true), - .half_up => return halfPageMove(pane, pl, text, range, false), - .down => return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)), - .up => return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)), + .half_down => return pane.halfPageMove(text, range, true), + .half_up => return pane.halfPageMove(text, range, false), + .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)), + .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: InsertAt = switch (insert.kind) { @@ -10495,7 +7504,7 @@ pub const Pardes = struct { } else { pane.select = true; if (pane.msel.active) { - setPaneRange(pane, pl, text, range, true); + pane.setRange(text, 0, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { @@ -10504,22 +7513,22 @@ pub const Pardes = struct { } pane.cur_pinned = true; }, - .line => return lineSelect(pane, pl, text, range, select.count), - .line_bounds => return lineBoundsSelect(pane, pl, text, range), - .shrink_to_line_bounds => return shrinkSelToLineBounds(pane, pl, text, range), - .collapse => return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false), + .line => return pane.lineSelect(text, range, select.count), + .line_bounds => return pane.lineBoundsSelect(text, range), + .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range), + .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, explicit); + return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, - .all => return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false), + .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { pane.nsel = 0; return; } - return multiSelAction(pane, pl, text, multi.kind, multi.count); + return pane.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |edit| switch (edit.kind) { @@ -10528,14 +7537,8 @@ pub const Pardes = struct { .change => return p.normalChange(pane), .yank => return p.normalYank(pane), .replace_with_yank => return p.normalReplaceYank(pane), - .paste_after => { - pane.count = edit.count; - return p.normalPaste(pane, false); - }, - .paste_before => { - pane.count = edit.count; - return p.normalPaste(pane, true); - }, + .paste_after => return p.pasteText(pane, p.yank orelse return, false, edit.count), + .paste_before => return p.pasteText(pane, p.yank orelse return, true, edit.count), .switch_case => return p.normalCase(pane, .toggle), .lowercase => return p.normalCase(pane, .lower), .uppercase => return p.normalCase(pane, .upper), @@ -10563,8 +7566,8 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .pipe_selection => |how| return p.startPipe(pane, how), - .search => return p.startSearch(pane, config.search_marker), + .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), + .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, ), @@ -10573,50 +7576,29 @@ pub const Pardes = struct { // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- - fn startPipe(p: *Pardes, pane: *Pane, how: normal_input.PipeBehavior) void { - // A buffer that IS a file, or the scratch that becomes one: a filter - // rewrites bytes the pane owns. Never a terminal (shell output cannot - // be rewritten), never a rendering that its next refill would discard. - const f = pane.file orelse return; - if (!output_pane.fileTraits(f.output).saves) return; - p.seedTail(pane); - if (!pane.tag_init) return; - const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(pipeMarker(how))) return; - pane.prompt = .{ .pipe = prompt_at }; - pane.pipe_how = how; - pane.tag_edit = true; - pane.tag_sel = false; - pane.mode = .insert; - pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); - } - - /// Snapshot command/cwd/ranges/selection bytes before emitting the id-only - /// effect. Every allocation is owned by pipe_wait, so the frontend can - /// copy it synchronously and the core can keep editing immediately after. fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = pane.file orelse return; - if (!output_pane.fileTraits(f.output).saves) return; + if (!panes.Output.fileTraits(f.output).saves) return; + const prompt = switch (pane.prompt) { + .pipe => |pipe| pipe, + else => return, + }; const tail = pane.tagSlice(); - const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; - const marker = pipeMarker(pane.pipe_how); + const armed = tail[@min(prompt.at, tail.len)..]; + const marker = pipeMarker(prompt.how); if (!std.mem.startsWith(u8, armed, marker)) return; const command = armed[marker.len..]; if (command.len == 0) return; - var ranges: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, f.content, 0, &ranges); + var ranges: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(f.content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. - const ninputs = if (pane.pipe_how.pipes()) got.n else 1; + const ninputs = if (prompt.how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; for (ranges[0..ninputs], 0..) |range, i| { - // A behaviour that sends no stdin still submits one input, empty: - // the runner's contract is one invocation per input, and `!` wants - // exactly one invocation with nothing on its stdin. - const bytes = if (pane.pipe_how.pipes()) bytes: { + const bytes = if (prompt.how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; @@ -10656,7 +7638,7 @@ pub const Pardes = struct { .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, - .how = pane.pipe_how, + .how = prompt.how, .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); @@ -10671,14 +7653,6 @@ pub const Pardes = struct { return null; } - /// Put a failed filter where it can be READ: the command, which selection - /// it was, what became of it, and the command's own stderr underneath. - /// - /// An `+Errors` pane rather than the message row, because a message row is - /// 256 bytes and one line, and the useful half of a shell failure is the - /// text the shell wrote — `sh: line 1: trr: command not found`, a compiler - /// diagnostic, a `jq` parse error with a column in it. Truncating that to - /// fit a row would throw away the reason and keep the label. fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void { var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); @@ -10701,17 +7675,12 @@ pub const Pardes = struct { if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; } else { // No diagnosis at all: nobody ran it. The detached daemon, the - // browser and the board all leave `pull_pipe` null on purpose. + // browser and the board all leave `pipe` null on purpose. w.writeAll("this session cannot run filters\n") catch return; } const content = out.toOwnedSlice() catch return; - // FOCUS STAYS WITH THE TEXT. `openRead` moves `p.active` to the buffer - // it opens, which is right for a `Grep` you asked to read and wrong for - // a report you did not: a failed filter should put the reason on screen - // and leave the cursor in the file you were filtering, ready to fix the - // command and press `|` again. const was = p.active; - output_pane.openErrors(p, wait.pane, content) catch |err| { + panes.Output.openErrors(p, wait.pane, content) catch |err| { p.gpa.free(content); // Nowhere to put the report is itself worth one line. p.reportError(wait.pane, "pipe", err); @@ -10720,10 +7689,6 @@ pub const Pardes = struct { if (p.panes[was] != null) p.active = was; } - /// WHERE range `i`'s output goes, as a span of the pre-edit content to be - /// replaced by it. The three writing behaviours differ in exactly this and - /// nothing else: `|` swaps the selection out, `!` opens a hole at its - /// start, `A-!` at its end. Null when the range no longer fits the text. fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); @@ -10736,14 +7701,6 @@ pub const Pardes = struct { }; } - /// WHAT range `i` receives. - /// - /// Two helix rules live here. A behaviour that sends no stdin ran the - /// command ONCE, so every range gets `outputs[0]` — ten cursors and `date` - /// give ten identical stamps rather than ten racing ones. And a command - /// that added a trailing newline to a selection which did not have one has - /// it taken back off, which is what keeps `| tr a-z A-Z` on one line from - /// splitting it in two. fn pipeOutput( wait: *const PendingPipe, outputs: []const []const u8, @@ -10781,16 +7738,10 @@ pub const Pardes = struct { const pane = p.panes[wait.pane] orelse return; if (pane.serial != wait.serial) return; const f = if (pane.file) |*file| file else return; - if (!output_pane.fileTraits(f.output).saves) return; - // THE FILE MOVED UNDER THE FILTER. One keystroke during a ten-second - // command was enough to discard the whole result in silence, which is - // indistinguishable from the filter having done nothing at all. + if (!panes.Output.fileTraits(f.output).saves) return; if (f.revision != wait.revision) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); - // `A-|` ran the command FOR ITS EFFECT. There is nothing to splice and - // the selection is left exactly where it was, which is the whole - // difference between it and `|`. if (wait.how == .ignore) return; const n = wait.nranges; @@ -10811,7 +7762,7 @@ pub const Pardes = struct { const final_len = std.math.add(usize, kept, total_output) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; - var new_ranges: [MAX_SELS]modal.HxRange = undefined; + var new_ranges: [Pane.max_selections]modal.Selection = undefined; var read_at: usize = 0; var write_at: usize = 0; for (0..n) |i| { @@ -10824,10 +7775,6 @@ pub const Pardes = struct { @memcpy(replacement[write_at .. write_at + output.len], output); write_at += output.len; const out_end = write_at; - // THE OUTPUT IS WHAT ENDS UP SELECTED, for all three behaviours - // that write — helix `shell()` builds its new range around the - // inserted text, keeping the original's direction, so a `!` can be - // followed straight by another operator on what it just produced. new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else @@ -10838,9 +7785,8 @@ pub const Pardes = struct { // One async request is one history transaction, even at 64 cursors. p.pushUndo(pane); - file_pane.setContent(p, f, replacement); - const pl = p.paneCursorLines(pane) catch return; - setPaneRanges(pane, pl, f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); + panes.File.setContent(p, f, replacement); + pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } @@ -10850,12 +7796,11 @@ pub const Pardes = struct { const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; - fn paneDir(pane: *const Pane) []const u8 { - // an inherited cwd is a live link that outranks a scratch buffer's own - // synthetic path: follow it to the pane it was opened from. + pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), - else => {}, + .owned => |cwd| return cwd, + .none => {}, } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| @@ -10864,41 +7809,47 @@ pub const Pardes = struct { return pane.cwdSlice(); } - /// `/` (and the Find builtin) on any pane: type the pattern into the tag - /// tail after `marker` — the existing modal one-line editor, visible while - /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. - pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { - p.seedTail(pane); - if (!pane.tag_init) return; - const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(marker)) return; - pane.prompt = .{ .search = prompt_at }; - pane.tag_edit = true; - pane.tag_sel = false; - pane.mode = .insert; - pane.pending = 0; - // tag_col and the prompt offset are both UTF-8 byte offsets. - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); - } - - /// Save on a pane with no file of its own — an output buffer or a terminal - /// — arms a PATH input in the tag, prefilled with the pane's directory (an - /// inherited scratch follows the pane it was opened from). submitSave - /// hands what you type to saveTo. - pub fn startSavePrompt(p: *Pardes, pane: *Pane) void { + pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { + search: []const u8, + pipe: modal.Normal.PipeBehavior, + save, + }) void { + const marker = switch (request) { + .search => |marker| marker, + .save => config.save_marker, + .pipe => |how| pipe: { + const file = pane.file orelse return; + if (!panes.Output.fileTraits(file.output).saves) return; + break :pipe pipeMarker(how); + }, + }; p.seedTail(pane); if (!pane.tag_init) return; + const dir = if (request == .save) paneDir(pane) else ""; + const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); + var room = pane.tag_tail.len - pane.tag_tail_len; + if (marker.len > room) return; + room -= marker.len; + if (dir.len > room) return; + room -= dir.len; + if (slash and room == 0) return; + const prefix_len = (p.tagPrefix(pane) catch return).len; const prompt_at: u16 = @intCast(pane.tag_tail_len); - if (!pane.appendTag(config.save_marker)) return; - const dir = paneDir(pane); - _ = pane.appendTag(dir); - if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/"); - pane.prompt = .{ .save = prompt_at }; + _ = pane.appendTag(marker); + if (request == .save) { + _ = pane.appendTag(dir); + if (slash) _ = pane.appendTag("/"); + } + pane.prompt = switch (request) { + .search => .{ .search = prompt_at }, + .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, + .save => .{ .save = prompt_at }, + }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; - pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); + pane.normal.clear(); + pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } /// Enter on a save input: the path is everything past the marker. @@ -10922,35 +7873,9 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (f.output != null) return; // nothing behind it yet: saveTo, with a path - // NOT marked saved here: the effect has only been QUEUED. `perform`'s - // `.save_file` arm cleans the pane if and only if the host says the - // bytes landed — see there. p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); } - /// Commit a path — prompted, typed after the word, or chorded onto it. - /// - /// The pane is left ALONE: a terminal stays a terminal, a results buffer - /// keeps its rows and its place in the n/N ring, and an open file keeps the - /// file it has, so `Save ` is a copy and never a rename. The one - /// pane that changes is the scratch New opened, which exists to become the - /// file you name and does (output traits: `saves`). - /// - /// A relative path resolves against the PANE's directory — the way a look - /// resolves a relative word — and never against whatever directory the - /// process happened to start in. `.`, `..` and doubled slashes normalize - /// with it, so `Save ./notes` and `Save notes` are one path and one answer - /// to "is this the file I already have open". - /// - /// What it will not do is guess. A path that names no FILE (empty, or - /// ending in `/` — the bare prompt prefill accepted with Enter), one that - /// carries a newline (a multi-line selection chorded onto the word), and - /// one that does not resolve ABSOLUTE (a terminal whose shell has not - /// reported a directory yet, where the alternative is writing into - /// whatever directory pardes was started in) are all refused, and say so - /// on the message row. That check comes FIRST because the scratch's branch - /// below rewrites the pane's identity: a host write can only fail silently - /// afterwards, so a buffer must never become a "file" that never existed. pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { const pane = p.panes[id] orelse return; if (path.len == 0 or path[path.len - 1] == '/' or @@ -10968,16 +7893,16 @@ pub const Pardes = struct { if (pane.isTerminal()) p.askWrite(id, pane.serial, full); return; }; - if (f.output != null and output_pane.fileTraits(f.output).saves) { + if (f.output != null and panes.Output.fileTraits(f.output).saves) { const owned = p.gpa.dupe(u8, full) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; // an ordinary file pane from here on - pane.cwd = .none; // its directory is now its own path's dirname + f.watch_after_save = true; + pane.clearCwd(); pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); return; } // its own path, spelled out: the in-place write, so the pane comes clean @@ -10985,15 +7910,8 @@ pub const Pardes = struct { p.askWrite(id, pane.serial, full); } - /// The bound on a save path, which travels inside its effect: nothing is - /// stashed, so two saves armed in one batch cannot be confused for each - /// other and a whole buffer is never copied to write it. - const SavePath = Effect.Buf(256); + const SavePath = Effect.Buf(effect_path_cap); - /// Ask the host to write this pane's text at `path` without touching the - /// pane. The bytes are the pane's own, so the drain reads them back off it - /// (perform, .save_text) the way save_file does — with `serial` saying - /// which pane asked, since the slot may be freed before the drain. fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { p.emit(.{ .save_text = .{ .pane = @intCast(id), @@ -11002,16 +7920,8 @@ pub const Pardes = struct { } }); } - /// Enter on an armed input: the pattern is everything past the marker's - /// `/` (so a pattern may itself contain slashes), and the marker names the - /// search — " Find /" walks the filesystem for NAMES, " Grep /" for - /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor - /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - // `s`/`S` have already applied themselves keystroke by keystroke; - // Enter re-runs the final pattern so a submit is one code path with - // the preview and cannot disagree with what is on screen. if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tagSlice(); const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; @@ -11025,33 +7935,13 @@ pub const Pardes = struct { else .text; p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); - // ...and the bare `/` GOES there. Find and Grep answer with OTHER - // files, and opening the first of them on submit would rearrange the - // screen before you have read what was found; `/` searched the text - // already in front of you, so its first hit is a scroll, not a jump. if (kind == .text) p.lookFirstHit(id); } - /// Land ON the first hit of a `/`, instead of beside a list of them. - /// - /// No new motion: the results buffer is FOCUSED and then the two ordinary - /// verbs run in the order a hand would run them — the step `n` is, and the - /// look Enter is. Focusing is the part that cannot be skipped and the - /// reason this is not just a call to the walk: lookWalkPanes deliberately - /// leaves the ACTIVE pane out of the ring, so `n` pressed straight after a - /// search steps whichever list was looked most recently, which in a - /// session with any history at all is not the one that just answered. - /// - /// Everything the walk needs to be reversible from here is left by the - /// step itself (landLookSpot's `look_at`), so `N` afterwards goes back to - /// the row above exactly as it would have if you had pressed `n` yourself. fn lookFirstHit(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const rid = pane.search_pane orelse return; const rp = p.panes[rid] orelse return; - // Nothing matched: the empty buffer is the answer, and aiming the walk - // at a list with no rows would send it round the ring into a NEIGHBOUR - // and open a row from some other search. if ((rp.file orelse return).content.len == 0) return; p.active = rid; p.lookWalk(1); @@ -11064,30 +7954,6 @@ pub const Pardes = struct { p.runBuiltin(config.look_cmd, rid, "", txt); } - /// Fill this pane's results buffer with everything matching `pat_raw` - /// (plain substring, case-insensitive) — ONE function for both searches, - /// because they differ only in where the rows come from and agree on every - /// row being a LOOK TARGET, which is what makes n/N work: - /// text (`/`) — the pane's own flat text, the same view Look and Execute - /// read, so a terminal searches its scrollback exactly as a file - /// searches its content. Rows are `location text`, the location ended - /// by a SPACE (a trailing `:` would read as part of it): the pane's - /// path when it has a real file, else `@pN`. - /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. - /// grep — the CONTENTS of every file under every pane's directory, - /// walked like `grep -R`. Rows are `path:LINE:COL-ENDCOL text`, the - /// path relative to THIS pane's directory (absolute for a hit outside - /// it). - /// Both searches that match TEXT name the match's whole span, so looking a - /// row — which is all n/N do — selects what matched rather than parking on - /// its first cell. Find's rows are bare paths and have nothing to span. - /// No matches = an empty buffer. - /// - /// `start` is where the WALK begins, which belongs to the gesture and not - /// to the search: a click POINTS at one of the hits, so its list is armed - /// there and the first step goes to the next one (acme's button-3 walking a - /// word). `/`, Find and Grep point at nothing, so their list is walked from - /// the top, which is also the only place a list of OTHER files could start. pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); @@ -11096,16 +7962,11 @@ pub const Pardes = struct { // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = paneDir(pane); - const out = try arena.alloc(u8, look.search_max_output_bytes); + const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { - // One walk per PLACE the session is open on: every pane's - // directory, minus the ones another pane's already contains, so a - // tree two panes sit in is greped once and a pane deep inside - // another's tree adds nothing. Slot order, so the same session - // gives the same buffer twice running. var roots: [MAX_PANES][]const u8 = undefined; var nroots: usize = 0; for (p.panes) |slot| { @@ -11130,12 +7991,12 @@ pub const Pardes = struct { nroots += 1; } for (roots[0..nroots]) |r| - out_len += try look.grep(arena, p.gpa, r, dir, pat, out[out_len..]); + out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]); } else if (kind == .find) { - out_len = try look.find(arena, dir, pat, out); - } else if (hasPdf(pane)) { + out_len = try filesystem.find(arena, dir, pat, out); + } else if (pane.hasPdf()) { if (comptime pdf_enabled) { - const found = try pdf_pane.searchRows( + const found = try panes.Pdf.searchRows( &pane.pdf.?, p.pdf_gpa, arena, @@ -11148,7 +8009,7 @@ pub const Pardes = struct { anchor = found.anchor; } } else { - const pl = try p.paneCursorLines(pane); + const lines = try p.paneCursorLines(pane); // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; @@ -11158,12 +8019,9 @@ pub const Pardes = struct { std.fs.path.basename(pane.file.?.path) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; - // the hit at or before the cursor is the one you are ON, so arming - // there makes the first step land on the NEXT one: a click on the - // second `foo` goes to the third, not back to the first. const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); - for (pl.lines, 0..) |ln, i| { + for (lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), @@ -11178,31 +8036,14 @@ pub const Pardes = struct { const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key - const from: output_pane.Origin = switch (kind) { + const from: panes.Output.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; - // A different pattern still gets its own buffer: two searches are two - // lists, both stay open at their sizes, and the new one stacks directly - // below this pane. Everything about landing the rows — which open - // buffer counts as this same search, keeping a refill's place, opening - // fresh when there is none — is output_pane.fillResults. - try output_pane.fillResults(p, id, dir, from, pat, content, anchor); - } - - /// Step to the next/previous row of this pane's results buffer and ACT on - /// it — which of the two acme verbs that is comes from the buffer's own - /// traits. A location list (every search, every language answer) Looks the - /// leading `path:LINE:COL` word; a command list (ThemeSel, FontSel) Execs - /// the whole row. False = no live results to step. - /// - /// n/N used to BE this, and are not any more (lookWalk): stepping a list - /// of places now selects and stops, because a step that also opened meant - /// you could not walk past a hit without landing on it. What still comes - /// through here is what is not n/N at all: `]d`/`[d`, whose whole job is - /// to GO to the next diagnostic, and acme's button-3, where clicking a - /// word that names nothing searches for it and goes to the first hit. + try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); + } + fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; @@ -11210,32 +8051,47 @@ pub const Pardes = struct { const rf = if (rp.file) |*f| f else return false; // one question covers both hazards: a freed slot can be reused by an // unrelated pane, and a buffer of PROSE has nowhere to step to - const tr = output_pane.fileTraits(rf.output); + const tr = panes.Output.fileTraits(rf.output); if (!tr.steps) return false; // fresh results: n starts at the first row, N has nothing behind it - const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n")); + const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (step < 0 or step >= nrows) return true; // armed, nowhere left to go - const r: i32 = @intCast(step); - pane.search_row = @intCast(step); - // select the result row in the results pane and keep it in view + _ = p.jumpResult(id, @intCast(step)); + p.active = id; + return true; + } + + fn jumpResult(p: *Pardes, id: usize, row: usize) bool { + const pane = p.panes[id] orelse return false; + const rid = pane.search_pane orelse return false; + const rp = p.panes[rid] orelse return false; + const rf = if (rp.file) |*f| f else return false; + const tr = panes.Output.fileTraits(rf.output); + const ln = modal.lineSlice(rf.content, row); + if (ln.len == 0) return false; + const r: i32 = @intCast(row); + pane.search_row = row; rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; - // in view, but WITHOUT scrolloff: a results pane is short, and a - // three-row margin on a seven-row one means every single n scrolls the - // list out from under the eye. A row already on screen moves nothing. const off = rp.scroll(); const last = off + @as(i32, rp.rows) - 1; if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last); - const ln = modal.lineSlice(rf.content, @intCast(step)); var realbuf: [4096]u8 = undefined; const span = if (tr.commands) wholeRowSpan(ln) else - look.lookableLineSpan(ln, paneDir(rp), &realbuf); + look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { + const target = panes.Output.location(ln); + break :blk if (target.at.line > 0) + look.Span{ .start = 0, .end = target.end } + else + null; + }; if (span) |selected| { rp.cur_col = @intCast(selected.start); rp.look_at = .{ @@ -11247,55 +8103,30 @@ pub const Pardes = struct { rp.cur_col = 0; rp.look_at = null; } - // Both arms are the BUILTIN, run on the results pane — the same call a - // middle or right click on that row would make, so a stepped row and a - // clicked row can never drift apart. A command row goes whole (its - // argument is the tail after the name); a location row is cut to the - // leading file-ish word, since the rest of it is the matched text. if (tr.commands) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); - } else { - var hi: usize = 0; - while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; - p.runBuiltin(config.look_cmd, rid, "", ln[0..hi]); + } else if (span) |selected| { + p.lookAt(rid, ln[selected.start..selected.end]); } - // the look may focus what it opened — a Find row opens a whole new - // file pane every time — so focus comes back to the pane that owns the - // search and the next n keeps stepping. A `/` row looks at the - // searching pane itself, so this is what already happened there. - p.active = id; + p.armLookWalk(rid); return true; } - /// Ask the backend something about the symbol under the cursor. Only a - /// real file can be asked: a terminal's rows are a program's output and an - /// output buffer is our own text, neither of which has a language behind - /// it. Unsupported kinds never get here (the keymap drops them), so a - /// backend that answers nothing simply never opens a buffer. pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!p.multiOnce()) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; - // `status` is about the BACKEND, not about a document, so it answers - // from ANY pane — a terminal, a +Search, anywhere. That matters - // precisely when the pane you are sitting in is the thing going wrong. - // Every other kind needs a real file: a terminal's rows are a - // program's output and an output buffer is our own text. if (kind != .status) { const f = pane.file orelse return; if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) - // A rename's argument becomes an identifier in someone's source. Zig - // buffers get Zig's exact rule; any other language the client speaks - // gets the weakest honest one (no whitespace, no quotes — the server - // validates the rest and answers nothing when it hates the name). if (kind == .rename) { const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true; if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return; if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return; } - const off = if (pane.file) |f| modal.hxOff(f.content, .{ + const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; @@ -11321,14 +8152,6 @@ pub const Pardes = struct { const LspEdit = struct { start: usize, end: usize, text: []const u8 }; - /// Parse a mutating response into ordered replacements. Two record forms, - /// never mixed: `@edit START END` substitutes `fallback` at every range - /// (the ZLS rename path — the text is the request's own argument), and - /// `@put START END PCT` carries its own percent-encoded replacement (a - /// protocol server's rename or format, whose per-range text only the - /// server knows). `fallback == null` rejects the @edit form outright — a - /// format response has no argument to substitute. Anything malformed - /// parses to null and null changes nothing. fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const a = p.scratch.allocator(); @@ -11384,10 +8207,6 @@ pub const Pardes = struct { return out[0..n]; } - /// Where the cursor lands after `edits` replace their ranges: text before - /// the first edit keeps its offset, text between edits shifts by the - /// accumulated delta, and a cursor inside a replaced range clamps into - /// the replacement. fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize { var old_at: usize = 0; var new_at: usize = 0; @@ -11402,10 +8221,6 @@ pub const Pardes = struct { return new_at + (old - old_at); } - /// Apply parsed edit records as ONE undo transaction, or nothing: ranges - /// must be ordered, non-overlapping and in bounds, and the file revision - /// must still be the one the worker was asked about — the user may have - /// typed while the server thought. True when the buffer changed. fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool { const f = if (pane.file) |*file| file else return false; if (f.revision != revision) return false; @@ -11426,7 +8241,7 @@ pub const Pardes = struct { const final_len = std.math.add(usize, f.content.len - removed, added) catch return false; const replacement = p.gpa.alloc(u8, final_len) catch return false; - const old_cursor = modal.hxOff(f.content, .{ + const old_cursor = modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }); @@ -11443,8 +8258,8 @@ pub const Pardes = struct { @memcpy(replacement[write_at..], f.content[read_at..]); p.pushUndo(pane); - file_pane.setContent(p, f, replacement); - const cursor = modal.hxPos(f.content, mapped_cursor); + panes.File.setContent(p, f, replacement); + const cursor = modal.positionAt(f.content, mapped_cursor); pane.cur_row = @intCast(cursor.row); pane.cur_col = @intCast(cursor.col); pane.vsel.active = false; @@ -11455,21 +8270,11 @@ pub const Pardes = struct { return true; } - /// A worker answered. The two MUTATING kinds are consumed first: a - /// response made of edit records is applied atomically (rename substitutes - /// the argument or the server's own text, `=` applies the formatter), and - /// only a response that is NOT records renders — a rename that spans other - /// files arrives as location rows and opens as a PREVIEW list instead of - /// being half-applied, and a format that could not run stays prose. - /// Every other response is the ordinary look/output path: - /// one row, a goto -> jump straight there (helix jumps on a single - /// location and shows a picker on several) - /// anything else -> an output buffer, which n/N already steps. That - /// buffer IS the picker; there was never one to write. - pub fn lspResponse(p: *Pardes, id: u32, rows: []const u8) void { + pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; + const rows = response orelse return; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; if (w.kind == .rename or w.kind == .format) { @@ -11489,107 +8294,51 @@ pub const Pardes = struct { } else return; } if (rows.len == 0) { - // No rows is a legal answer everywhere except here. Tab DIVERTED - // instead of indenting, so an empty answer would eat the keystroke - // — a dot in a comment, a dot in a string, a half-typed line - // nothing can be made of — and a Tab that silently does nothing is - // worse than not having the feature. So the indent happens now, - // late, on the condition that nothing has moved: same pane, still - // in insert, one cursor, and the cursor still on the cell the Tab - // was pressed at. Anyone who kept typing during the query gets - // nothing rather than four spaces landing 300ms behind their hands. if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane); return; } - const from: output_pane.Origin = .{ .query = w.kind }; + const from: panes.Output.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); - if (output_pane.traits(from).jumps and nrows == 1) { + if (panes.Output.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); - var hi: usize = 0; - while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; - // exactly what a `/` result row does when n steps onto it: look the - // `path:LINE:COL` token from the pane that asked, so placement, - // dedup-onto-an-open-pane and centering are the ONE look path. - // (helix would also push its jumplist here; pardes has none, so - // there is nothing to push — do not read this as one.) - return p.lookAt(w.pane, ln[0..hi]); + return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; - // Landing the rows is runSearch's path exactly, keyed on the KIND - // rather than the argument (fillResults reads that off the origin). - // Why the refill is not optional here: docs/lsp.md. - output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| + panes.Output.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| { p.reportError(w.pane, "language response", err); + return; + }; + if (panes.Output.traits(from).jumps) { + const result = p.panes[pane.search_pane orelse return] orelse return; + const file = pane.file orelse return; + const row = panes.Output.nextResult(result.file.?.content, lsp.rel(dir, file.path), .{ + .line = @as(usize, @intCast(@max(0, w.row))) + 1, + .col = @as(usize, @intCast(@max(0, w.col))) + 1, + }); + _ = p.jumpResult(w.pane, row); + } } // ---- n/N: the walk over look-able text ---- - /// How many rows ONE PRESS may scan, across every pane it visits. A - /// shell's motion surface is its whole scrollback and every whitespace run - /// on it costs a realpath, so the walk is bounded. - /// - /// Running out STOPS the walk where it stands rather than treating the - /// pane as exhausted and moving on, and that distinction is load-bearing: - /// giving up in the middle of a pane and hopping to the next one would - /// make the two directions disagree about where a pane ENDS, and n/N have - /// to be exact inverses. Not moving is the one failure that always is. - /// A pane whose next look-able text is eight thousand rows away is a pane - /// to scroll, not to step. const max_look_rows = 8192; - /// Where a step STARTS inside a pane. `col` null enters the pane at the - /// row's edge — every span on it is ahead of you — which is what a hop - /// from a neighbouring pane does. `strict` says the column is a position - /// the walk itself established, so the span sitting ON it is the one you - /// are already at and the step must go past it. const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; - /// Entering a pane from a neighbour: the first row going forward, the last - /// going back. Spelled once because it is exactly what makes the two - /// directions inverses across a pane boundary. fn lookEdge(delta: i32) LookFrom { return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; } - /// Where the walk currently stands in `pane`. - /// - /// `Pane.look_at` and not the cursor alone, because the cursor cannot - /// answer the question. A cursor parked on the first look-able span may - /// mean the walk put it there — so the next step is the SECOND span — or - /// that the pane simply opened that way, which is every fresh +Search, and - /// there the next step must be the FIRST. `search_row` answered the same - /// question the same way for the same reason. When the recorded stand no - /// longer matches the cursor you have moved it yourself since, and the - /// cursor wins: the walk continues from where you are looking. fn lookStand(pane: *Pane) LookFrom { if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col) return .{ .row = s.row, .col = s.col0, .strict = true }; return .{ .row = pane.cur_row, .col = pane.cur_col }; } - /// The panes n/N walk, in the order it walks them: every pane that has - /// performed a LOOK, most recent first, then the OUTPUT buffers none has, - /// newest first — and, only when that comes to nothing at all, the pane - /// you are in. - /// - /// The look history is the spine because looking is what marks a pane as - /// the one you are reading things OUT of — the +Search you are stepping, - /// the diagnostics list, the shell whose `ls` rows you keep opening. The - /// unlooked output buffers come after it so a fresh `/`, which has looked - /// at nothing yet, still has somewhere for the first `n` to go: its own - /// results. FILO among them, so two searches step the newer list first. - /// - /// The ACTIVE pane is the fallback and NOT a member, which is the - /// difference between n continuing a list and n wandering off it. Look a - /// row out of a +Search and focus lands in the file that opened; the next - /// n has to go back to the +Search, not start walking the paths that - /// happen to be in the source you just opened. Only when nothing has - /// looked and no buffer has answered — a shell one minute into a session, - /// which is where n/N started life — is the pane in front of you the list. fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize { var n: usize = 0; var i = p.n_look_src; @@ -11624,13 +8373,6 @@ pub const Pardes = struct { return out[0..n]; } - /// Is a span starting at `col0` PAST `from` in the direction of travel? - /// Only the row a walk STARTED on is filtered — every span on a row it - /// arrived at is ahead of it — and the comparison is against `col0` rather - /// than the whitespace run's start. Those are different columns the moment - /// a wrapper is peeled: `(mise.toml)` is a run starting at 0 and a span - /// starting at 1, and a backward step filtered on the run would find the - /// span it is standing on still ahead of it and never leave the row. fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { if (!on_start_row) return true; const c = from.col orelse return true; @@ -11638,9 +8380,6 @@ pub const Pardes = struct { return if (from.strict) col0 < c else col0 <= c; } - /// The whole row as one span, first non-blank cell to last — the `.whole` - /// grain. The trailing trim keeps a padded row selecting the command and - /// not the padding. fn wholeRowSpan(ln: []const u8) ?look.Span { var lo: usize = 0; while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; @@ -11648,33 +8387,11 @@ pub const Pardes = struct { return if (hi > lo) .{ .start = lo, .end = hi } else null; } - /// The next STEPPABLE span in `pane` from `from`, in `delta`'s direction, - /// or null when the pane has none left that way. `budget` is the caller's - /// remaining row allowance and is spent here; a null return with a budget - /// of zero means GAVE UP, not exhausted (see max_look_rows). - /// - /// WHAT A SPAN IS comes from the pane's grain (output_pane.Grain) and is - /// the one thing about this motion a buffer gets to change: - /// .word free text — a terminal, a file, a PDF — where a row may hold - /// several places and every look-able run is a stop: an `ls` - /// line hops big.txt -> plain.txt -> sub (look.lookableSpan). - /// .line a results buffer, where a row IS one location: one stop per - /// row, on the largest run its head resolves as, and the matched - /// text after it is not a second stop (look.lookableLineSpan). - /// .whole a command list (ThemeSel, FontSel), where the line is the - /// word: `Theme gruvbox` has no path inside it to pick out. - /// Same motion, same selection, same Enter/Tab afterwards. - /// - /// Symmetric by construction in all three, and that is the whole point: - /// both directions ask the same question about the same rows, and both - /// compare against `col0` — the column the walk parks the cursor on. So a - /// step forward off a span and a step back onto it are the same two - /// positions read in the two orders. - fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?LookSpot { - const pl = p.paneCursorLines(pane) catch return null; - const nrows: i32 = @intCast(pl.lines.len); + fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { + const lines = p.paneCursorLines(pane) catch return null; + const nrows: i32 = @intCast(lines.len); if (nrows == 0) return null; - const grain: output_pane.Grain = if (pane.file) |*f| output_pane.grain(f.output) else .word; + const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); @@ -11682,18 +8399,18 @@ pub const Pardes = struct { while (r >= 0 and r < nrows) : (r += delta) { if (budget.* == 0) return null; budget.* -= 1; - const ln = pl.lines[@intCast(r)]; + const ln = lines[@intCast(r)]; const on_start = r == start; switch (grain) { .word => { - var best: ?LookSpot = null; + var best: ?Pane.LookSpot = null; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; - const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue; + const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; const col0: i32 = @intCast(t0 + sp.start); if (!lookPast(col0, from, on_start, delta)) continue; best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; @@ -11705,7 +8422,7 @@ pub const Pardes = struct { // scan past: the row either offers it or it does not .line, .whole => { const sp = (if (grain == .line) - look.lookableLineSpan(ln, dir, &realbuf) + look.lineSpan(p, ln, dir, &realbuf) else wholeRowSpan(ln)) orelse continue; const col0: i32 = @intCast(sp.start); @@ -11717,56 +8434,10 @@ pub const Pardes = struct { return null; } - /// n/N: move the SELECTION to the next/previous look-able text and open - /// NOTHING. Enter looks what this leaves selected, and that separation is - /// the change: a step is a motion you can take twenty of and then decide, - /// where it used to be twenty panes. - /// - /// The sequence stepped is the concatenation, in lookWalkPanes' order, of - /// each pane's look-able spans in document order, AND IT IS A RING. `n` is - /// the next position on that ring and `N` the previous one, computed the - /// same way from the same state — so x presses one way and x back land - /// exactly where you started, across pane boundaries included: a pane - /// entered forward is entered at its FIRST span, and leaving it backward - /// from that span drops into the previous pane's LAST. - /// - /// A ring rather than a list with two ends, for two reasons that turn out - /// to be one. A shell's cursor sits at the PROMPT, below everything it has - /// printed, so a walk that could not come round would have nowhere to go - /// on the very first press — which is the case n/N was written for. And a - /// ring is still exactly reversible, so nothing is given up for it: acme's - /// search has always been one, and this is that. - /// - /// (One press is not symmetric, and cannot be: from a cursor the walk has - /// never stood on, the first step ACQUIRES a position rather than moving - /// one — see lookStand. Every press after that is exact.) - /// - /// Position stays in each pane (`look_at`); the one global serial records - /// only WHICH stream owns the next step after a Look moves focus away. - /// Serials make deletion/reuse stale safely, and refilling a list simply - /// re-arms that list without manufacturing a second cursor. - /// - /// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode: - /// n/N are this walk in all of them, which is the other half of making - /// them trustworthy. A PDF used to step its results buffer and jump; it - /// steps the same ring now, which IS that buffer, and Enter does the - /// jumping. The single thing any buffer gets to change is the GRAIN of - /// what a step selects, and it changes it by BEING a kind of buffer rather - /// than by a branch here (output_pane.Grain, read in lookSpanIn): free - /// text steps every look-able word, a results list steps one ROW at a time - /// — its head is the location and the rest is the match — and a command - /// list steps the whole line, because a ThemeSel row is a word to run and - /// not a place to go. - /// - /// `]d`/`[d` are not n/N. They are helix's diagnostic motions, their job - /// is to ARRIVE at the next diagnostic, and they still reach searchStep. fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; - // A Look may move focus away from the list it came from. Continue the - // explicitly armed origin first; only a stale/missing owner falls back - // to the pane under focus and then the history head. const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null; const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0; const at = if (owner) |wanted| @@ -11774,10 +8445,6 @@ pub const Pardes = struct { else active_at; var from = lookStand(p.panes[order[at]] orelse return); - // ...then every OTHER pane once, in the direction of travel, entered - // at its edge — and `k == order.len` brings the starting pane round a - // second time, from ITS edge, which is the wrap. That bound is also - // what makes a screen with nothing look-able on it terminate. var budget: usize = max_look_rows; var k: usize = 0; while (k <= order.len) : (k += 1) { @@ -11791,11 +8458,7 @@ pub const Pardes = struct { } } - /// Select `spot` and focus its pane. The selection is EXPLICIT so Enter's - /// look chord acts on it, with the anchor on the span's last cell and the - /// cursor on its FIRST — the same shape the old terminal stepper left, and - /// the reason `col0` is the position the walk compares against. - fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: LookSpot) void { + fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void { p.pinPaneCursor(pane); // fresh out of tty mode the cursor still tracks the shell pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; pane.msel.active = false; @@ -11810,16 +8473,13 @@ pub const Pardes = struct { } /// Route shared edit operations to a file's content or a terminal overlay. - fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?panes.EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; - if (pane.image != null or hasPdf(pane)) return null; - return term_pane.editText(p, pane, lo, hi, col); + if (pane.image != null or pane.hasPdf()) return null; + return panes.Terminal.editText(p, pane, lo, hi, col); } - /// editText for an op whose selection can END on a line's newline cell: - /// eating that newline joins with the line BELOW, so a terminal's buffer - /// has to cover that row too (a file's content always already does). - fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?EditText { + fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?panes.EditText { const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); if (r + 1 < modal.lineCount(eb.text)) return eb; @@ -11829,14 +8489,14 @@ pub const Pardes = struct { /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - if (pane.file) |*f| return file_pane.setContent(p, f, new); - term_pane.setEditText(p, pane, new); + if (pane.file) |*f| return panes.File.setContent(p, f, new); + panes.Terminal.setEditText(p, pane, new); } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { - if (hasPdf(pane)) return; + if (pane.hasPdf()) return; p.pinPaneCursor(pane); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected @@ -11844,19 +8504,15 @@ pub const Pardes = struct { pane.select = false; pane.append_at = null; pane.sticky_col = -1; - const pl = p.paneCursorLines(pane) catch { + const text = p.flatSurface(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; - pane.pending = 0; + pane.normal.clear(); return; }; - const text = p.flatSurface(pane, pl) catch return; - const cur = toModalCursor(pane, pl); - const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; - // helix selection-aware entry: `i` to the selection's START (the - // selection flips and survives until the first edit); `a` one past - // its END, remembering the origin cell for the Esc restore + const cur = pane.toModalCursor(); + const line = panes.File.textLine(pane, text, cur.row); const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { @@ -11873,19 +8529,19 @@ pub const Pardes = struct { const hi_row = if (b) |bb| bb.hi_row else pane.cur_row; const hi_col = if (b) |bb| bb.hi_col else pane.cur_col; pane.append_at = .{ .row = lo_row, .col = lo_col }; - const gap = modal.nextGrapheme(text, modal.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.hxPos(text, gap); + const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); + const gc = modal.positionAt(text, gap); pane.cur_row = @intCast(gc.row); pane.cur_col = @intCast(gc.col); pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; pane.cur_pinned = true; }, .line_start => { - fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); + pane.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) }); pane.vsel.active = false; }, .line_end => { - pane.cur_col = @intCast(llen); + pane.cur_col = @intCast(line.len); pane.cur_pinned = true; pane.vsel.active = false; }, @@ -11895,7 +8551,7 @@ pub const Pardes = struct { const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); - const ind = modal.hxIndentString(modal.lineSlice(eb.text, row)); + const ind = modal.indentText(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return; const block = arena.alloc(u8, block_len) catch return; @@ -11922,28 +8578,23 @@ pub const Pardes = struct { pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; if (cnt > 1 and !p.multi_on) opened: { - const pl2 = p.paneCursorLines(pane) catch break :opened; - const t2 = p.flatSurface(pane, pl2) catch break :opened; - var rs: [MAX_SELS]modal.HxRange = undefined; - const m = @min(cnt, MAX_SELS); + const t2 = p.flatSurface(pane) catch break :opened; + var rs: [Pane.max_selections]modal.Selection = undefined; + const m = @min(cnt, Pane.max_selections); for (0..m) |k| { - const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len }); + const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, pane.cur_row) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } - setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); + pane.setRanges(t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); } - /// insert mode. ONE path for both pane kinds: a file edits its content, a - /// terminal edits the buffer standing in for its shell rows (editText - /// materializes and grows it), so typing, Enter, joins and the kill runs - /// mean exactly the same thing in a shell pane as in a document. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return p.insertKey(pane, key); p.replaySels(pane, .{ .insert = key }); @@ -11954,38 +8605,30 @@ pub const Pardes = struct { if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); - // a selection carried into insert (i/a) survives only until the next - // key: helix maps it through every edit, pardes drops it instead — - // its only pardes use (the acme chords) needs explicit selections - // anyway, and those never enter insert mode pane.vsel.active = false; if (!pane.cur_pinned) p.pinPaneCursor(pane); - // arrows and paging are pure motion over the WHOLE surface, so they - // run before editText — a terminal must not freeze shell rows into an - // edit buffer just because you walked across them switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur0 = pane.toModalCursor(); const nc = switch (key.cp) { - Key.left => modal.charLeft(pl.lines, cur0), - Key.right => modal.charRight(pl.lines, cur0), - Key.up => insertVerticalCursor(pl.lines, cur0, false), - Key.down => insertVerticalCursor(pl.lines, cur0, true), + Key.left => modal.charLeft(lines, cur0), + Key.right => modal.charRight(lines, cur0), + Key.up => Pane.insertVerticalCursor(lines, cur0, false), + Key.down => Pane.insertVerticalCursor(lines, cur0, true), else => cur0, }; - fromModalCursor(pane, pl, nc); + pane.fromModalCursor(nc); pane.ensureCursorVisible(); return; }, Key.page_up, Key.page_down => { // helix binds insert pageup/pagedown to the same view // scroll + cursor snap as normal mode - const pl = p.paneCursorLines(pane) catch return; - const flat = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, flat, pl.row0); + const flat = p.flatSurface(pane) catch return; + const range = pane.primaryRange(flat, 0); const step: i32 = @intCast(@max(1, pane.rows)); - scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); + pane.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { @@ -11996,9 +8639,9 @@ pub const Pardes = struct { }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); - pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); + const text = p.flatSurface(pane) catch return; + const cur0 = pane.toModalCursor(); + pane.cur_col = @intCast(panes.File.textLine(pane, text, cur0.row).len); pane.cur_pinned = true; pane.ensureCursorVisible(); return; @@ -12023,19 +8666,16 @@ pub const Pardes = struct { } // helix insert-mode kills (word/line; deleteSpan is exclusive) if (hit(key, config.delete_word_backward)) { - // helix delete_word_backward: to the previous word start — - // crossing the newline at col 0, which takes the word before it - // too, so on the buffer's first line a terminal grows up one row const e2 = if (c.row == 0 and c.col == 0) (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; - const g = modal.hxOff(e2.text, c2); + const g = modal.offsetAt(e2.text, c2); if (g == 0) return; - const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); - const fc = modal.hxPos(e2.text, from); + const fc = modal.positionAt(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; @@ -12045,19 +8685,16 @@ pub const Pardes = struct { return; } if (hit(key, config.delete_word_forward)) { - // helix delete_word_forward: to the next word END (trailing - // whitespace survives), crossing newlines at line ends — at the - // buffer's last line a terminal grows down one row to allow it const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; - const g = modal.hxOff(e2.text, c2); - const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); + const g = modal.offsetAt(e2.text, c2); + const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); const to = @max(wr.anchor, wr.head); if (to <= g) return; - const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.hxPos(e2.text, to)) catch return; + const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; @@ -12080,7 +8717,7 @@ pub const Pardes = struct { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); - const indent = modal.hxNewlineIndentWidth(line, c.col); + const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @@ -12134,31 +8771,10 @@ pub const Pardes = struct { pane.cur_pinned = true; }, Key.tab => { - // Tab straight after a `.` asks the language backend what - // could go there — an output buffer of DEFINITIONS, one row - // per candidate, not an autocomplete popup and not an - // insertion. Only where an answer is possible: a terminal, an - // output buffer or a file the backend does not speak still - // indents, because a Tab that silently does nothing is worse - // than not having the feature. The extension list stays the - // backend's (lsp.speaks); this only asks. (An answer that - // comes back EMPTY indents too, late — see lspResponse.) - // - // Never with several cursors. A language query is a - // per-KEYSTROKE action inside a per-SELECTION replay, so - // multiOnce would stop the replay dead: the other cursors - // would neither ask nor indent and the whole multicursor would - // collapse on a Tab. Every other insert key applies to all of - // them, and so does this one — by indenting. const ln = modal.lineSlice(text, c.row); if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: { const f = pane.file orelse break :dot; if (f.output != null or !lsp.speaks(f.path)) break :dot; - // speaks() is the fast path only — lspRequest has four - // bails of its own (unsupported kind, dead pane, output - // buffer, multiOnce) and each one would eat the Tab. The - // seq bump is the one honest "the question went out", so - // ask and fall through to the indent if it did not. const seq = p.lsp_seq; p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; @@ -12169,9 +8785,6 @@ pub const Pardes = struct { } } - /// helix insert_tab with a Spaces indent style: spaces to the next tab - /// stop (smart-tab machinery skipped). A function because lspResponse - /// presses the same key, a turn of the loop later. fn insertTab(p: *Pardes, pane: *Pane) void { const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const c: modal.Cursor = .{ @@ -12189,7 +8802,7 @@ pub const Pardes = struct { const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// a range's two cells, normalized to document order - fn cellBounds(s: SelRange) Bounds { + fn cellBounds(s: Pane.SelRange) Bounds { if (s.row < s.arow or (s.row == s.arow and s.col < s.acol)) return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol }; return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col }; @@ -12199,30 +8812,23 @@ pub const Pardes = struct { fn vselBounds(pane: *Pane) Bounds { return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } - /// the char selection as text. Read off the pane's SURFACE (file content / - /// terminal shell rows + edit buffer), not the rendered body: a yank of a - /// whole line has to carry its newline, the way a file's does, or p/P - /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); - const text = if (pane.file) |f| f.content else surface: { - const pl = p.paneCursorLines(pane) catch return ""; - break :surface p.flatSurface(pane, pl) catch return ""; - }; + const text = p.flatSurface(pane) catch return ""; return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } /// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); - const pl = p.paneCursorLines(pane) catch return ""; + const lines = p.paneCursorLines(pane) catch return ""; const rows_count: usize = @intCast(@max(0, r1 - r0 + 1)); var total: usize = rows_count -| 1; var i = r0; while (i <= r1) : (i += 1) { - if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) - total += pl.lines[@intCast(i)].len; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) + total += lines[@intCast(i)].len; } const out = arena.alloc(u8, total) catch return ""; var at: usize = 0; @@ -12232,8 +8838,8 @@ pub const Pardes = struct { out[at] = '\n'; at += 1; } - if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) { - const line = pl.lines[@intCast(i)]; + if (i >= 0 and @as(usize, @intCast(i)) < lines.len) { + const line = lines[@intCast(i)]; @memcpy(out[at..][0..line.len], line); at += line.len; } @@ -12262,7 +8868,7 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); return; } if (pane.msel.active) { @@ -12321,32 +8927,17 @@ pub const Pardes = struct { } } - /// helix p/P: the DEFAULT register, after/before the selection. - fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { - p.pasteText(pane, p.yank orelse return, before); - } - - /// ...and the paste itself, over text from wherever: the register above, - /// or the system clipboard `SPC p` asked the shell for, which deliberately - /// never passes through the register on its way here. - /// - /// Text ending in '\n' pastes as whole lines below/above the SELECTION's - /// line span; anything else splices inline at the selection's outer edge. - /// The paste (repeated times) becomes the implicit selection, - /// cursor on its last char (linewise: ON the last pasted line's newline). - fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool) void { + fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) void { if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; - const cnt: usize = @max(1, pane.count); - pane.count = 0; const arena = p.scratch.allocator(); var y: []const u8 = y0; - if (cnt > 1) { - const total = std.math.mul(usize, cnt, y0.len) catch return; + if (count > 1) { + const total = std.math.mul(usize, count, y0.len) catch return; const buf = arena.alloc(u8, total) catch return; - for (0..cnt) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); + for (0..count) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); y = buf; } const b: Bounds = if (pane.vsel.active) @@ -12357,9 +8948,6 @@ pub const Pardes = struct { const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; - // a yanked BLANK line is "\n": the block is empty and lineCount - // says 0 lines, but it still pastes as one (empty) line — without - // the floor every `n - 1` below underflows and panics. const n = @max(1, modal.lineCount(block_text)); var out: []u8 = undefined; if (before) { @@ -12420,7 +9008,7 @@ pub const Pardes = struct { p.gpa.free(d.deleted); const n = modal.lineCount(d.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); - const ind = modal.hxIndentString(modal.lineSlice(d.content, row)); + const ind = modal.indentText(modal.lineSlice(d.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; @@ -12431,7 +9019,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); return; } @@ -12445,9 +9033,9 @@ pub const Pardes = struct { p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; - clampCursor(pane, d.content, eb.row0); + pane.clampCursor(d.content, eb.row0); pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); return; } if (pane.msel.active) { @@ -12492,7 +9080,7 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); pane.ensureCursorVisible(); } else { p.pushUndo(pane); @@ -12506,18 +9094,10 @@ pub const Pardes = struct { const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; - pane.pending = 0; + pane.normal.clear(); } } - // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- - // Terminals go through the same edit buffer as files: since editText - // materializes one over whatever rows the op names, every one of these - // works the same in a shell pane as in a document. - - /// the selection as an inclusive cursor range in `content`, whose first - /// line is absolute row `row0`: vsel span, msel line span, else the char - /// under the cursor (helix's implicit 1-wide selection) fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); @@ -12553,7 +9133,7 @@ pub const Pardes = struct { fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; - const before = paneRange(pane, eb.text, eb.row0); + const before = pane.primaryRange(eb.text, eb.row0); const lo = @min(before.anchor, before.head); const hi = @max(before.anchor, before.head); var graphemes: usize = 0; @@ -12566,11 +9146,11 @@ pub const Pardes = struct { const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); const end = lo + graphemes * encoded_len; - const mapped: modal.HxRange = if (before.head < before.anchor) + const mapped: modal.Selection = if (before.head < before.anchor) .{ .anchor = end, .head = lo } else .{ .anchor = lo, .head = end }; - setPaneRange(pane, .{ .lines = &.{}, .row0 = eb.row0 }, new, mapped, pane.vsel.explicit); + pane.setRange(new, eb.row0, mapped, pane.vsel.explicit); } /// `R`: replace the selection (or the cursor char) with the DEFAULT @@ -12620,9 +9200,6 @@ pub const Pardes = struct { /// `from`. Ascending and disjoint. const TextChange = struct { from: usize, to: usize, ins: []const u8 }; - /// map an original-text offset through a change list (insertions AT a - /// position push it right — helix Assoc::After; positions inside a - /// deleted span collapse to its start) fn mapThroughChanges(chs: []const TextChange, pos: usize) usize { var delta: i64 = 0; for (chs) |ch| { @@ -12658,20 +9235,15 @@ pub const Pardes = struct { return out; } - /// `J`: helix join_selections — join the selection's line span (a bare - /// cursor joins with the next line): each '\n' + following indent become - /// one space, EXCEPT before content-less lines (no space) — and on the - /// buffer's last line the trailing newline is deleted. The selection and - /// cursor map through the edit; the count is ignored (helix). fn normalJoin(p: *Pardes, pane: *Pane) void { // a join always eats the newline of its last line, so the buffer has // to reach one row PAST the selection const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; - const range = paneRange(pane, text, eb.row0); - const span = rangeLineSpan(text, range); - const nlines = modal.hxLineCount(text); + const range = pane.primaryRange(text, eb.row0); + const span = Pane.rangeLineSpan(text, range); + const nlines = modal.cursorLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; @@ -12680,26 +9252,26 @@ pub const Pardes = struct { var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { - const from = modal.hxLineEndIdx(text, l); + const from = modal.lineEndOffset(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; + const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; chs_len += 1; } if (chs_len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); - const cc = modal.hxPos(new, nc); - const ac = modal.hxPos(new, na); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12712,18 +9284,13 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// `>` / `<`: helix indent/unindent over the selection's line span. - /// Blank (all-whitespace) lines are skipped; `>` inserts count levels - /// realigned to the next INDENT_W stop; `<` removes up to count levels of - /// leading whitespace (a tab advances to the next stop). Cursor and - /// selection map through the edit. fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { pane.select = false; const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; - const range = paneRange(pane, text, eb.row0); - const span = rangeLineSpan(text, range); + const range = pane.primaryRange(text, eb.row0); + const span = Pane.rangeLineSpan(text, range); const arena = p.scratch.allocator(); // one run of spaces, sliced per line: `>` never inserts more than this const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; @@ -12733,7 +9300,7 @@ pub const Pardes = struct { var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) @@ -12762,17 +9329,17 @@ pub const Pardes = struct { } if (chs_len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); - const cc = modal.hxPos(new, nc); - const ac = modal.hxPos(new, na); + const cc = modal.positionAt(new, nc); + const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { @@ -12785,19 +9352,6 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets - /// the language's line-comment token put in front of it — or taken off, - /// and WHICH of the two is decided once for the whole set: one uncommented - /// non-blank line among them and everything gets commented. That single - /// decision is why Action.scope runs this once instead of per cursor; - /// replayed, a half-commented block would end up half-commented the other - /// way round. - /// - /// The rest is helix's find_line_comment, quirks included: the token goes - /// in at the SHALLOWEST indent in the set (so a deeper line is commented - /// mid-whitespace), all-blank lines are skipped entirely and do not vote, - /// and uncommenting also eats one space after the token unless some line - /// lacks it. fn normalToggleComment(p: *Pardes, pane: *Pane) void { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; pane.select = false; // helix exit_select_mode @@ -12807,19 +9361,13 @@ pub const Pardes = struct { b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); } - // ...and ONE ROW PAST them, like normalJoin: a selection may end on - // its last line's newline cell, and a terminal buffer that stops at - // that line has nowhere to put it (a file's content always does). - // Never past the surface's own last row, though — materialising a row - // that does not exist yet would ADD a blank line to the pane, and this - // op may well decide to change nothing. - const pl0 = p.paneCursorLines(pane) catch return; - const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 1; + const surface = p.flatSurface(pane) catch return; + const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 1; const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; const text = eb.text; - var rs: [MAX_SELS]modal.HxRange = undefined; - const got = paneRanges(pane, text, eb.row0, &rs); - const nlines = modal.hxLineCount(text); + var rs: [Pane.max_selections]modal.Selection = undefined; + const got = pane.ranges(text, eb.row0, &rs); + const nlines = modal.cursorLineCount(text); const arena = p.scratch.allocator(); // the lines the ranges cover, each ONE ONCE and in order (helix's // min_next_line: two cursors on one line comment it once) @@ -12827,7 +9375,7 @@ pub const Pardes = struct { var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { - const span = rangeLineSpan(text, r); + const span = Pane.rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) { @@ -12836,10 +9384,6 @@ pub const Pardes = struct { } next = @max(next, end); } - // which token: the file's EXTENSION, which is the same thing - // src/syntax.zig tells languages apart by, read off the one table in - // config. A terminal and an output buffer have no extension and get - // the default, which is what helix does for a buffer with no language. const ext = if (pane.file) |f| std.fs.path.extension(f.path) else ""; var token: []const u8 = config.comment_token_default; lang: for (config.comment_tokens) |row| { @@ -12853,7 +9397,7 @@ pub const Pardes = struct { var margin: usize = 1; var live: usize = 0; for (lines[0..lines_len]) |l| { - const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)]; + const line = text[modal.lineStartOffset(text, l)..modal.lineEndOffset(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; indent = @min(indent, nw); @@ -12867,7 +9411,7 @@ pub const Pardes = struct { var chs_len: usize = 0; for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); - const le = modal.hxLineEndIdx(text, l); + const le = modal.lineEndOffset(text, l); const line = text[ls..le]; if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched const at = ls + indent; @@ -12882,15 +9426,15 @@ pub const Pardes = struct { p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) - var cells: [MAX_SELS]SelRange = undefined; + var cells: [Pane.max_selections]Pane.SelRange = undefined; const new = p.applyChanges(text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { - const c = rangeCells(new, .{ + const c = Pane.rangeCells(new, .{ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor), .head = mapThroughChanges(chs[0..chs_len], r.head), }); - const cc = modal.hxPos(new, c.cur); - const ac = modal.hxPos(new, c.anc); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); cells[i] = .{ .row = @as(i32, @intCast(cc.row)) + eb.row0, .col = @intCast(cc.col), @@ -12899,28 +9443,23 @@ pub const Pardes = struct { }; } p.setEditText(pane, new); - const pl2 = p.paneCursorLines(pane) catch return; - const t2 = p.flatSurface(pane, pl2) catch return; - for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col); - setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl); + const t2 = p.flatSurface(pane) catch return; + for (cells[0..got.n], 0..) |s, i| rs[i] = Pane.cellRange(t2, s.arow, s.acol, s.row, s.col); + pane.setRanges(t2, rs[0..got.n], &.{}, got.pri, expl); } - /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal - /// integer (helix: the selected fragment itself, no number scan around - /// the cursor); a fragment that isn't an integer is a no-op. The new - /// number becomes the selection, cursor on its last char. fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return; - const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; + const rep = (modal.incrementDecimal(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; p.setEditText(pane, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; - const cc = modal.hxPos(new, modal.prevGrapheme(new, start + rep.len)); + const cc = modal.positionAt(new, modal.prevGrapheme(new, start + rep.len)); pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; @@ -12964,43 +9503,39 @@ pub const Pardes = struct { }; } - /// `mi` / `ma`: select inside/around a textobject (pure range - /// math over the motion surface — works on terminals too). Word and - /// paragraph objects are helix textobject.rs ports; pairs/quotes are the - /// plain-text scans (quotes line-scoped — ponytail). - fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { - const text = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, text, pl.row0); + fn textobjectSelect(p: *Pardes, pane: *Pane, text: []const u8, obj: u21, around: bool) void { + const range = pane.primaryRange(text, 0); switch (obj) { 'w', 'W' => { - const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectWord(text, range, around, obj == 'W'); + return pane.setRange(text, 0, r, false); }, 'p' => { - const r = modal.hxTextobjectParagraph(text, range, around, 1); - return setPaneRange(pane, pl, text, r, false); + const r = modal.selectParagraph(text, range, around, 1); + return pane.setRange(text, 0, r, false); }, else => {}, } - const cur = modal.hxPos(text, modal.hxCursor(text, range)); + const lines = p.paneCursorLines(pane) catch return; + const cur = modal.positionAt(text, modal.selectionCursor(text, range)); const pair: ?modal.Range = switch (obj) { - '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), - '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), - '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), - '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), - '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), + '\'', '"', '`' => modal.enclosingQuote(lines, cur, @intCast(obj)), + '(', ')' => modal.enclosingPair(lines, cur, '(', ')'), + '[', ']' => modal.enclosingPair(lines, cur, '[', ']'), + '{', '}' => modal.enclosingPair(lines, cur, '{', '}'), + '<', '>' => modal.enclosingPair(lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; - var a = modal.hxOff(text, pr.a); - var head = modal.nextGrapheme(text, modal.hxOff(text, pr.b)); + var a = modal.offsetAt(text, pr.a); + var head = modal.nextGrapheme(text, modal.offsetAt(text, pr.b)); if (!around) { // inside: shrink off the delimiters; an EMPTY pair collapses to // a 1-wide cursor on the closing char (helix) a = modal.nextGrapheme(text, a); head = modal.prevGrapheme(text, head); } - setPaneRange(pane, pl, text, .{ .anchor = a, .head = head }, false); + pane.setRange(text, 0, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap @@ -13026,27 +9561,25 @@ pub const Pardes = struct { } /// `md`: delete the enclosing pair's chars; the cursor maps through - fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { + fn surroundDelete(p: *Pardes, pane: *Pane, ch: u21) void { const pr = pairFor(ch) orelse return; - // the pair is found over the motion surface, whose rows are absolute; - // the edit runs in the buffer covering those rows, and every offset - // below is taken in THAT text so the cursor maps through it - const cur = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (pr.o == pr.c) - modal.enclosingQuote(pl.lines, cur, pr.o) + modal.enclosingQuote(lines, cur, pr.o) else - modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; + modal.enclosingPair(lines, cur, pr.o, pr.c)) orelse return; const lo: i32 = @intCast(@min(r.a.row, cur.row)); const hi: i32 = @intCast(@max(r.b.row, cur.row)); - const eb = p.editText(pane, lo + pl.row0, hi + pl.row0, -1) orelse return; + const eb = p.editText(pane, lo, hi, -1) orelse return; const text = eb.text; - const drow = pl.row0 - eb.row0; // surface row -> buffer row + const drow = -eb.row0; // surface row -> buffer row const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); - const a_off = modal.hxOff(text, ra); - const b_off = modal.hxOff(text, rb); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); + const a_off = modal.offsetAt(text, ra); + const b_off = modal.offsetAt(text, rb); + const cur_off = modal.offsetAt(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, text, rb) catch return; p.setEditText(pane, new); @@ -13055,7 +9588,7 @@ pub const Pardes = struct { var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; - const cc = modal.hxPos(new, nc); + const cc = modal.positionAt(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; @@ -13065,16 +9598,17 @@ pub const Pardes = struct { } /// `mr`: swap the enclosing pair's chars for 's - fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { + fn surroundReplace(p: *Pardes, pane: *Pane, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; - const cur = toModalCursor(pane, pl); + const lines = p.paneCursorLines(pane) catch return; + const cur = pane.toModalCursor(); const r = (if (fp.o == fp.c) - modal.enclosingQuote(pl.lines, cur, fp.o) + modal.enclosingQuote(lines, cur, fp.o) else - modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; - const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; - const drow = pl.row0 - eb.row0; // surface row -> buffer row + modal.enclosingPair(lines, cur, fp.o, fp.c)) orelse return; + const eb = p.editText(pane, @as(i32, @intCast(r.a.row)), @as(i32, @intCast(r.b.row)), -1) orelse return; + const drow = -eb.row0; // surface row -> buffer row const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); @@ -13086,57 +9620,25 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - /// pull the cursor back inside `text` after a rewrite; `row0` is the - /// absolute surface row of its first line (0 for a file) - fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { - const n = modal.lineCount(text); - const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(text, row).len; - pane.cur_row = @as(i32, @intCast(row)) + row0; - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); - pane.cur_pinned = true; - pane.vsel.active = false; - pane.msel.active = false; - pane.ensureCursorVisible(); - } - fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; - if (pane.file != null) return file_pane.pushUndo(p, pane); - term_pane.pushUndo(p, pane); + if (pane.file != null) return panes.File.pushUndo(p, pane); + panes.Terminal.pushUndo(p, pane); } fn doUndo(p: *Pardes, pane: *Pane) void { - if (pane.file != null) return file_pane.undo(p, pane); - term_pane.undo(p, pane); + if (pane.file != null) return panes.File.undo(p, pane); + panes.Terminal.undo(p, pane); } fn doRedo(p: *Pardes, pane: *Pane) void { - if (pane.file != null) return file_pane.redo(p, pane); - term_pane.redo(p, pane); + if (pane.file != null) return panes.File.redo(p, pane); + panes.Terminal.redo(p, pane); } pub const ChromeTarget = struct { col: u16, row: u16 }; - /// Is this cell layout CHROME, and if so which cell should the press be - /// delivered at? For the touch shells: a finger on a tag row or a resize - /// handle latches a left-mouse drag, everything else is body text and gets - /// one-finger scrolling and tap-as-look. A gesture is classified once, at - /// finger-down, and never turns into a scroll afterwards. - /// - /// It lives here because it is a MIRROR of handleMouse's own hit test - /// below, in both the geometry and the ORDER: the move box beats a - /// horizontal handle on a tag-only pane, the rest of the tag row beats the - /// fat-finger tolerance around a separator, and Tagbottom moves both the - /// tag row and the h-handle together (a pane's tag on its LAST row makes - /// its first an ordinary body row and puts the seam on the lower pane's - /// first). It was a line-for-line clone in web.zig and gui.zig, kept in - /// step by a comment in each saying it was a clone of the other; the two - /// conditionals Tagbottom added went into both copies four times. - /// - /// The one-cell tolerance is the only thing here that is not handleMouse's - /// rule: a mouse is exact, a finger is not. pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { if (row < TOPBAR_H) return .{ .col = col, .row = row }; for (p.panes, 0..) |slot, id| { @@ -13153,7 +9655,7 @@ pub const Pardes = struct { for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { - const rect = p.rects[p.col_terms[column][index]]; + const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } @@ -13172,7 +9674,7 @@ pub const Pardes = struct { for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { - const rect = p.rects[p.col_terms[column][index]]; + const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle }; } @@ -13190,14 +9692,10 @@ pub const Pardes = struct { } const PointerTextSelection = struct { - sel: Sel, + sel: Pane.Sel, on_tag: bool, }; - /// Map one physical grid cell into the selection coordinate space shared - /// by Look, Exec and the hover preview. Geometry lives here once: the tag - /// is always selection row zero and body rows start at BOX_H, even when - /// Tagbottom swaps their physical positions. fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection { if (p.panes[id] == null) return null; const r = p.rects[id]; @@ -13233,9 +9731,6 @@ pub const Pardes = struct { return null; } - /// Debounce by semantic grid cell rather than raw motion events. A host - /// may report the same pixel position every frame; those reports must not - /// postpone the preview forever. fn noteLookHover(p: *Pardes, col: u16, row: u16) void { const delay = config.look_preview_delay_frames orelse return; _ = delay; @@ -13254,14 +9749,9 @@ pub const Pardes = struct { p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial }; } - /// Re-evaluate a stationary pointer against the frame the host just - /// presented. `noteLookHover` preserves an existing delay or preview only - /// when this still resolves to the same canonical cell and pane lifetime; - /// moving to another semantic target re-arms it, and an invisible panel - /// cell cancels it. fn refreshLookHoverFromRaw(p: *Pardes) void { if (!p.pointer_inside or !p.raw_hover_intent) return; - const mapped = p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row) orelse + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; @@ -13277,8 +9767,8 @@ pub const Pardes = struct { if (pane.serial != waiting.serial) return p.cancelLookHover(); const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); - if (comptime pdf_enabled) if (!pointed.on_tag and pdf_pane.paneNativeReady(p, pane)) { - const probe = pdf_pane.probeAt(p, pane, waiting.col, waiting.row) orelse + if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { + const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); const ready = waiting.*; p.look_hover_wait = null; @@ -13307,190 +9797,10 @@ pub const Pardes = struct { p.look_hover_wait = null; } - const PointerCell = struct { col: u16, row: u16 }; - - fn boxContainsCell(box: panel_animation.Box, col: u16, row: u16) bool { - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } - - /// Input follows the panel pixels the shaders/TTY compositor present. - /// Opening panels are drawn last, so they are probed first here too. A - /// cell in final geometry which has not appeared yet is deliberately not - /// clickable; otherwise a user could act on invisible content. - fn presentedPointer(p: *const Pardes, col: u16, row: u16) ?PointerCell { - if (p.panel_presentation_pending) return null; - // Closing pixels belong to a dead pane lifetime. They occlude the - // canonical survivor underneath while visible, but can never dispatch - // into either that survivor or a slot which reused the old pane id. - var closing = p.npresented_closing_panel_tracks; - while (closing > 0) { - closing -= 1; - const track = p.presented_closing_panel_tracks[closing]; - if (!track.active()) continue; - if (boxContainsCell(track.contentBox(), col, row) and - boxContainsCell(track.presented(), col, row)) return null; - } - const phases = [_]panel_animation.Phase{ .opening, .moving }; - for (phases) |phase| { - // Backends paint pane slots forward within a phase. Probe them in - // reverse so overlapping transition quads address the top pixel. - var index = p.presented_panel_tracks.len; - while (index > 0) { - index -= 1; - const track = p.presented_panel_tracks[index] orelse continue; - if (!track.active() or track.phase != phase) continue; - switch (track.effect) { - .slide, .zoom => { - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); - const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); - return .{ - .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, p.screen_w -| 1))), - .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, p.screen_h -| 1))), - }; - }, - .vertical => { - const clip = track.contentBox(); - if (!boxContainsCell(clip, col, row)) continue; - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) - return null; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - return .{ - .col = @intFromFloat(@floor(clip.x + u * clip.w)), - .row = @intFromFloat(@floor(clip.y + v * clip.h)), - }; - }, - .dissolve, .ascii => { - if (!boxContainsCell(track.to, col, row)) continue; - if (!p.panelCellChanged(col, row)) - return .{ .col = col, .row = row }; - const relative_col: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, - )); - const relative_row: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, - )); - const visible = switch (track.effect) { - .dissolve => panel_animation.dissolveRevealed( - track.serial, - relative_col, - relative_row, - track.amount(), - ), - .ascii => switch (p.panelCellDiff(col, row)) { - .ascii => |diff| diff.complete(track.frame), - .unchanged, .visual => true, - }, - else => unreachable, - }; - return if (visible) .{ .col = col, .row = row } else null; - }, - // Every glyph in a motion effect's pane is in flight, - // changed or not, so a cell becomes a truthful input target - // only once its own glyph has settled on the canonical one. - .edges, .fall, .wave, .curtain, .scramble, .typewriter => { - if (!boxContainsCell(track.to, col, row)) continue; - const area = panel_animation.CellArea.of(track.to); - const settled = std.meta.eql( - panel_animation.charSource( - track, - col -| area.x0, - row -| area.y0, - area, - ), - panel_animation.CharSource.settled, - ); - return if (settled) .{ .col = col, .row = row } else null; - }, - .off => {}, - } - } - } - for (p.presented_panel_tracks) |maybe| { - const track = maybe orelse continue; - if (!track.active() or (track.effect != .slide and track.effect != .zoom and - track.effect != .vertical)) continue; - if (boxContainsCell(track.to, col, row)) return null; - } - return .{ .col = col, .row = row }; - } - - /// Commit the exact panel samples a backend successfully presented. An - /// empty slice means that backend drew the canonical grid directly. - /// Records are keyed by pane slot so hit-test order stays identical to the - /// renderer even when a native ABI publishes them in paint order. - pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const panel_animation.Track) void { - var presented: [MAX_PANES]?panel_animation.Track = @splat(null); - var presented_closing: [MAX_PANES]panel_animation.Track = undefined; - var nclosing: usize = 0; - var layout: [MAX_PANES]?LayoutSnapshot = if (p.submitted_panel_layout_ready) - p.submitted_panel_layout - else - @splat(null); - if (!p.submitted_panel_layout_ready) for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - }; - for (&layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { - const pane = p.panes[id] orelse { - snapshot.* = null; - continue; - }; - if (pane.serial != saved.serial) snapshot.* = null; - }; - for (tracks) |track| { - if (track.phase == .closing) { - const current = for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |candidate| { - if (candidate.serial == track.serial and candidate.pane == track.pane and - candidate.effect == track.effect) break true; - } else false; - if (!current or !track.active() or nclosing == presented_closing.len) continue; - presented_closing[nclosing] = track; - nclosing += 1; - continue; - } - const id: usize = track.pane; - if (id >= p.panes.len or !track.active()) continue; - const pane = p.panes[id] orelse continue; - if (pane.serial != track.serial) continue; - presented[id] = track; - layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; - } - p.presented_panel_tracks = presented; - p.presented_closing_panel_tracks = presented_closing; - p.npresented_closing_panel_tracks = nclosing; - p.presented_panel_layout = layout; - p.panel_presentation_ready = true; - p.panel_presentation_pending = false; - // An empty acknowledgement means canonical cells, not an invisible - // transition. Retire any producer records a direct/headless fallback - // deliberately did not present, then make this exact frame the future - // old-grid baseline. Animated acknowledgements keep their baseline - // frozen until the canonical endpoint is actually presented. - if (tracks.len == 0) { - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - p.rememberPresentedCells(); - } - // A held gesture follows the pixels just acknowledged even when the - // physical pointer stayed still. Its payload already is the last - // successfully mapped endpoint, so an invisible sample simply leaves - // that endpoint intact for a later frame or balanced release. + pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { + p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { - if (p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row)) |mapped| { + if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { p.hover_col = mapped.col; p.hover_row = mapped.row; p.dragUpdate(mapped.col, mapped.row); @@ -13499,57 +9809,8 @@ pub const Pardes = struct { p.refreshLookHoverFromRaw(); } - fn rememberPresentedCells(p: *Pardes) void { - p.panel_diff_pending = false; - p.panel_diff_ready = false; - const cells = p.surface.cells; - if (cells.len == 0) { - p.presented_cells_valid = false; - p.presented_cells_layout = @splat(null); - return; - } - if (p.presented_cells.len != cells.len) { - const next = p.gpa.alloc(Cell, cells.len) catch { - p.presented_cells_valid = false; - p.presented_cells_layout = @splat(null); - return; - }; - if (p.presented_cells.len > 0) p.gpa.free(p.presented_cells); - p.presented_cells = next; - } - @memcpy(p.presented_cells, cells); - p.presented_cells_cols = p.surface.cols; - p.presented_cells_rows = p.surface.rows; - p.presented_cells_valid = true; - p.presented_cells_layout = if (p.submitted_panel_layout_ready) - p.submitted_panel_layout - else - @splat(null); - } - - fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool { - if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return false; - return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col].changed(); - } - - fn panelCellDiff(p: *const Pardes, col: u16, row: u16) PanelCellDiff { - if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or - p.panel_cell_diffs.len != @as(usize, p.screen_w) * p.screen_h) return .unchanged; - return p.panel_cell_diffs[@as(usize, row) * p.screen_w + col]; - } - - /// A backend is about to replace an unpresentable animated frame with the - /// canonical grid. Stop producer tracks too, so a later successful retry - /// cannot resume halfway through an animation after canonical was shown. pub fn abandonPanelAnimations(p: *Pardes) void { - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.presented_panel_tracks = @splat(null); - p.npresented_closing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - p.panel_presentation_pending = p.panel_presentation_ready; + p.presentation.cancel(); p.cancelLookHover(); } @@ -13571,11 +9832,8 @@ pub const Pardes = struct { return; } p.raw_hover_intent = false; - const mapped = p.presentedPointer(raw_col, raw_row) orelse { + const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse { p.cancelLookHover(); - // A disappearing transition cell may hide the release, but the - // press it balances already has a last valid endpoint in Drag. - // End only the gesture owned by this exact physical button. if (m.kind == .release) p.dragRelease(m.button); return; }; @@ -13596,9 +9854,6 @@ pub const Pardes = struct { p.chord_arg = null; } } - // ...and any press at all takes the keyboard back off the topbar: the - // mouse names where focus goes, so leaving a cursor parked on row 0 - // while you click into a pane would just be a lie if (m.kind == .press) p.topbar_col = null; switch (m.button) { @@ -13610,41 +9865,33 @@ pub const Pardes = struct { _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; const pane = p.panes[id].?; - if (hasPdf(pane)) - pdf_pane.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) + if (pane.hasPdf()) + panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) else pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; - // a mostly-vertical two-finger swipe's sideways drift dies - // here rather than sliding the view out from under a scroll. - // Charged against the gesture, not the pane, so it runs before - // we ask what is hovered. if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; - if (hasPdf(pane)) { - pdf_pane.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); + if (pane.hasPdf()) { + panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back } else if (pane.file != null and !p.settings.wrap) { // wrapped there is nothing off to the right to reach - const line = file_pane.sourceLine(pane, pane.cur_row); - const visual = file_pane.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); + const line = panes.File.sourceLine(pane, pane.cur_row); + const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); const next: usize = if (m.button == .wheel_right) visual +| @as(usize, @intCast(config.wheel_cols)) else visual -| @as(usize, @intCast(config.wheel_cols)); - pane.hscroll = @intCast(file_pane.rawAtDisplay(line, next)); + pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next)); } }, config.select_button => switch (m.kind) { .press => { - // acme 2-1: a select press during an EXECUTE drag captures - // a selection (heldSelection) as the execute's argument; - // the execute drag keeps running to its release, which - // consumes it. if (p.drag == .select and p.drag.select.button == config.exec_button) { if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); @@ -13658,15 +9905,12 @@ pub const Pardes = struct { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(config.look_button)].state = .none; if (comptime pdf_enabled) - pdf_pane.pointerCancel(t, p.drag.select.pdf); + panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert - // the layout box (gutter cells of the tag row) wins over - // the resize handles: a tag-only pane's single row IS its - // pair's h-handle, and the box must stay grabbable to move if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; @@ -13675,17 +9919,6 @@ pub const Pardes = struct { return; } } - // resize handles next: a pane's own trailing edge (v: the - // left column's last col; h: the seam row between a stacked - // pair that is a BODY row — the upper pane's last, or with - // Tagbottom, where that one is the upper pane's tag, the - // lower pane's first). - // The v test still wins outright, but it now also asks - // whether this same cell is one of the adjoining columns' h - // handles — that cell is the corner where the two lines - // meet, and grabbing it drags both boundaries at once. Its - // OWN column is asked first, so a row where both are split - // is the gesture it always was (see Drag.border_v). for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k| @@ -13708,11 +9941,6 @@ pub const Pardes = struct { const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; - // the same two rows renderPane painted through (see there): - // the tag's, and the body's first. A Sel row is still 0 for - // the tag and BOX_H upward for the body whichever end they - // are at, so the mapping happens HERE and everything - // downstream of a Sel is untouched. const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; if (mcol < r.x + config.GUTTER) { @@ -13720,10 +9948,6 @@ pub const Pardes = struct { p.active = id; pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y))); } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { - // left press on the tag row: focus the tag AT that - // column (a rendered-tag column — the mouse and the tag - // agree, no clamp into the tail) and anchor a sweep, so - // dragging selects any span of it, path included p.active = id; p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; @@ -13734,15 +9958,11 @@ pub const Pardes = struct { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; - // Ctrl rides on the drag rather than firing here: the - // click does not place the modal cursor until RELEASE - // (dragRelease), and a goto asked at press time would - // answer about wherever the cursor happened to be. p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, - .pdf = pdf_pane.pointerStart( + .pdf = panes.Pdf.pointerStart( p, pane, config.select_button, @@ -13771,7 +9991,7 @@ pub const Pardes = struct { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(p.drag.select.button)].state = .none; if (comptime pdf_enabled) - pdf_pane.pointerCancel(t, p.drag.select.pdf); + panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; @@ -13782,15 +10002,7 @@ pub const Pardes = struct { if (m.button == config.exec_button) { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); - const word = wordAtCol(bar, file_pane.rawAtDisplay(bar, mcol)); - // a topbar word runs on the PRESS — there is no - // drag to chord into, so the argument is simply - // whatever is selected right now: select a word, - // ...but only for a builtin that HAS somewhere to - // put one (see takesArg): a word left selected in - // some pane an hour ago is not an argument to - // Kill, and splicing it made a name that matches - // no builtin and therefore went to a shell. + const word = wordAtCol(bar, panes.File.rawAtDisplay(bar, mcol)); const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) @@ -13809,17 +10021,11 @@ pub const Pardes = struct { const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { - // gutter: right scrolls DOWN to here, mirroring left's up; - // middle matches left. Right focuses (look lands you - // there); middle does not. const local = @as(i32, mrow) - @as(i32, body_y); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| { if (m.button == config.look_button) p.active = id; - // Click and delayed hover enter through the same physical - // cell mapper. From here on both also share expandedSel; - // hover merely stores the result outside Pane.sel. pane.sel[@intFromEnum(m.button)] = pointed.sel; p.drag = .{ .select = .{ @@ -13827,7 +10033,7 @@ pub const Pardes = struct { .button = m.button, // A native body gesture stays native even when its // point later misses a letterbox or selection. - .pdf = pdf_pane.pointerStart( + .pdf = panes.Pdf.pointerStart( p, pane, m.button, @@ -13862,14 +10068,9 @@ pub const Pardes = struct { clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol) else mcol; - // a corner also drives its column's pane pair, off the SAME - // mouse position but through its own clamp — the geometry a - // clamp reads (widths for x, heights for y) is frozen for the - // whole drag and never crosses axes, so one edge parked at its - // stop leaves the other tracking the mouse if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { - const a = p.rects[p.col_terms[k.col][k.idx]]; - const b = p.rects[p.col_terms[k.col][k.idx + 1]]; + const a = p.rects[p.col_panes[k.col][k.idx]]; + const b = p.rects[p.col_panes[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else { d.cur_y = mrow; @@ -13879,8 +10080,8 @@ pub const Pardes = struct { const cc = d.col; const k = d.top_idx; if (k + 1 < p.col_n[cc]) { - const a = p.rects[p.col_terms[cc][k]]; - const b = p.rects[p.col_terms[cc][k + 1]]; + const a = p.rects[p.col_panes[cc][k]]; + const b = p.rects[p.col_panes[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, @@ -13893,20 +10094,6 @@ pub const Pardes = struct { const r = p.rects[s.id]; const b = @intFromEnum(s.button); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); - // A Tagbottom drag STAYS IN THE REGION ITS ANCHOR STARTED - // IN, which is the one place the two coordinate spaces - // genuinely disagree: Sel row 0 is the tag and the body - // counts up from BOX_H, but on screen the tag is now BELOW - // the body. Sweeping down past the last line onto the - // tagline — the ordinary "select to the end" gesture — would - // therefore drive r1 to the TOP of Sel space and invert the - // selection: the highlight jumps above the anchor, and a 1-2 - // Cut on it deletes lines nobody swept. So a body-anchored - // drag is clamped to the body rows and a tag-anchored one - // (execute/look only; the left button's tag sweep is its own - // .tag drag) stays on the tag, which is one line anyway. - // The cost is that a body sweep can no longer be extended - // onto the bottom tagline to pick up the tag text. if (p.settings.tag_bottom) { const body_h = r.h -| BOX_H; pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) @@ -13915,33 +10102,20 @@ pub const Pardes = struct { @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); if (comptime pdf_enabled) - pdf_pane.pointerUpdate(&s.pdf, p, pane, mcol, mrow); + panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .tag => |d| { - // the tag cursor follows the mouse across the rendered tag; the - // row is ignored (a tag is one line) and the anchor stays where - // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(@max(0, c))))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(@max(0, c))))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } - /// THE ACME INVERSION: while a script holds this pane's `event` file open, - /// buttons 2 and 3 in it belong to the script. The words in its tag are - /// ITS commands — `Step`, `Run`, `Clear` — and pardes has never heard of - /// them, so it reports the click and performs nothing. Returns true when - /// the caller must not run the builtin. - /// - /// Keyboard Enter/Tab are deliberately NOT suppressed, unlike acme (which - /// has no keyboard equivalent to suppress): a scripted pane stays - /// editable, and a script that dies mid-run cannot leave you unable to - /// execute anything in it. fn reportGesture( p: *Pardes, id: usize, @@ -13953,46 +10127,27 @@ pub const Pardes = struct { ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; - const action: acmefs.Action = if (is_look) + const action: filesystem.Action = if (is_look) (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; const range = p.gestureRange(id, text, on_tag, operand); - // acme(4)'s two flag vocabularies at the same bit positions. For an - // exec, bit 1 is "this is a builtin"; for a look, it is "pardes can - // act on this without loading a file", which is the same fact plus a - // word that is not a path. Bit 2 is acme's "the text indicated is a - // null string that has a non-null expansion", which is exactly what an - // empty range plus text means here. - var flag: u32 = if (named) acmefs.flag_builtin else 0; - if (range.q0 == range.q1 and text.len > 0) flag |= acmefs.flag_expansion; + var flag: u32 = if (named) filesystem.flag_builtin else 0; + if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.flag_expansion; if (is_look) { - if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= acmefs.flag_filename; - } else if (chorded) flag |= acmefs.flag_chorded; - return acmefs.noteAction(p, id, action, range.q0, range.q1, flag, text); - } - - /// THE BYTE RANGE A GESTURE NAMES, in the coordinates `addr` and `data` - /// speak — and it must name the TEXT REPORTED WITH IT, because a script - /// that does not recognise a record writes it back and pardes then - /// re-derives the text from these two numbers. A range that started at the - /// pointer instead of at the operand would execute `l D` for a click on - /// the `l` of `Del`. - /// - /// So the start comes from whatever the operand actually resolved: an - /// expanded word's own column, or the leading corner of the selection it - /// reused. When neither is available — a modal `v`/`x` selection, a - /// terminal's projected screen, a PDF — the answer is acme's null range at - /// the click, which its flag bit 2 already has a meaning for: the text - /// travels, the range does not claim to be it. - fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) acmefs.PaneFs.Range { + if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename; + } else if (chorded) flag |= filesystem.flag_chorded; + return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text); + } + + fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = @min(sel.c0, sel.c1); - const at = file_pane.rawAtDisplay(tag, @intCast(@max(0, lead))); + const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } @@ -14005,14 +10160,14 @@ pub const Pardes = struct { const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H); if (top < 0) break :lead null; const w = pane.wrapAt(top); - const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - config.PREFIX_W); + const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), }; } else null; const cursor = start orelse return .{}; - const q0: u32 = @intCast(@min(modal.hxOff(f.content, cursor), f.content.len)); + const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; } @@ -14027,10 +10182,6 @@ pub const Pardes = struct { p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const operand = text orelse { - // Nothing expanded — a click on `#`, `*`, `|`, a blank cell. A - // WATCHED pane still owns it: the script decides what a character - // pardes has no word for means, and life.py's grid is made of - // exactly those characters. if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null); return; }; @@ -14061,22 +10212,18 @@ pub const Pardes = struct { p.col_weight[c + 1] = pair - left; } } - // and the corner's other half. The two commits are independent - // — column weights are widths, pane vweights are heights, and - // neither reads the other — so the order here does not matter - // and a failed one cannot lose the other. if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) - p.applyRowSplit(k.col, k.idx, d.cur_y); + layout.applyRowSplit(p, k.col, k.idx, d.cur_y); } }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) - p.applyRowSplit(d.col, d.top_idx, d.cur_y), + layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .move => |d| { - p.moveTerm(d.id, d.cur_x, d.cur_y); + layout.movePane(p, d.id, d.cur_x, d.cur_y); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { - if (p.layoutFindTerm(d.id)) |f| if (f.col == 0) + if (layout.findPane(p, d.id)) |f| if (f.col == 0) p.evictLonePristineTty(0, d.id); }; }, @@ -14084,7 +10231,7 @@ pub const Pardes = struct { const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) { if (s.pdf.native) { - var release = pdf_pane.pointerRelease( + var release = panes.Pdf.pointerRelease( p, pane, s.pdf, @@ -14111,60 +10258,34 @@ pub const Pardes = struct { const b = @intFromEnum(s.button); pane.sel[b].state = .done; if (s.button == config.select_button) { - // keep a dragged selection highlighted; a plain click clears - // it. Either way pin the cursor; files also enter normal - // mode, terminals keep the mode they had (tty keeps the - // mouse usable, insert keeps typing where you clicked). const sl = pane.sel[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].state = .none; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { - // the row the user clicked, as the LAST FRAME drew it: - // which line it showed and the byte column it started - // at, so a click lands on the character under the - // pointer whether or not that row is a continuation const w = pane.wrapAt(body_vis); pane.cur_row = w.line; pane.cur_col = p.paneByteAtDisplay( pane, w.line, w.at, - sl.c1 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0), + sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; - pane.pending = 0; - // A fresh left gesture replaces every modal selection. - // Keeping an explicit v/x anchor made a plain click - // extend it, leaving no mouse-only way to dismiss it. + pane.normal.clear(); pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is - // Ctrl-click IS `gd`, asked now that the cursor has - // landed — the mouse spelling of the keyboard motion, - // through the identical request. A ctrl-DRAG still - // selects and still asks, about where it started, - // which is the same thing `gd` would answer. if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { - // acme execute (middle) / look (right): a no-drag click - // expands to the word under it first; a captured chord - // argument rides along and is consumed here. - // WHERE THE CLICK LANDED, before any expansion — as a body - // position, the way the left button converts its drag end. const clk = pane.sel[b]; const operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) pane.sel[b].state = .none; - // A look that names no file SEARCHES from where you are, so - // the click has to say where that is and the search walks - // forward from there. Not PINNED — a shell's cursor still - // belongs to the shell, and the search reads this in the - // same event. if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) { pane.cur_row = operand.row; pane.cur_col = operand.col; @@ -14182,38 +10303,6 @@ pub const Pardes = struct { } } - /// Commit a horizontal border to the pane weights: in column `cc`, the pane - /// at index `k` and the one under it split their combined height at row - /// `cur_y`. Shared by the plain h-drag and the h half of a corner drag — - /// the same gesture landing on the same boundary must settle identically - /// whether or not a column edge came along for the ride. - fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { - if (k + 1 >= p.col_n[cc]) return; - const a = p.panes[p.col_terms[cc][k]] orelse return; - const b = p.panes[p.col_terms[cc][k + 1]] orelse return; - const ra = p.rects[p.col_terms[cc][k]]; - const rb = p.rects[p.col_terms[cc][k + 1]]; - const combined: f32 = @floatFromInt(ra.h + rb.h); - // the handle is the upper pane's LAST row (inclusive, hence the +1), or - // with Tagbottom the lower pane's FIRST — which is that row's height - // already, with nothing to add. Either way a no-drag click hands back - // exactly ra.h and nothing moves. - var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); - nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); - const pair = a.vweight + b.vweight; - a.vweight = pair * (nt / combined); - b.vweight = pair - a.vweight; - } - - /// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left - /// button holds a selection drag. The first tap converts the mouse - /// selection into the modal one (vsel anchored at the drag start, cursor - /// at its end; a plain click is a bare cursor). Cut yanks + deletes the - /// selection; Paste replaces it with the yank register, or splices the - /// register in literally at a bare cursor — so 1-2 then 1-3 in one hold - /// nets out to Snarf (copy: the text returns, the register keeps it). - /// In a tty-mode shell 1-3 instead pastes into the pty (forwarded click - /// + bracketed paste) and 1-2 is inert. fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; @@ -14228,13 +10317,6 @@ pub const Pardes = struct { return; }; if (pane.mode == .tty) { - // tty: nothing can be cut — 1-2 stays inert; the pty owns the - // screen. 1-3 pastes like a terminal emulator: the click is - // forwarded first (only when the app listens for mouse) so - // mouse-aware programs put their cursor under it, then the - // register is typed — bracketed when the app set mode 2004 - // (readline/vim/helix strip the markers), else with \n turned - // to \r like any unbracketed paste. if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; // drop the sweep highlight @@ -14242,14 +10324,11 @@ pub const Pardes = struct { if (cut) return; const y = p.yank orelse return; if (y.len == 0) return; - if (term_pane.reportsMouse(pane)) { - // dragUpdate keeps sel[sel_slot] tracking the held select button, so - // the click lands where the mouse is at this tap; 1-based, - // body-relative (the tag row is ours, not the app's) + 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, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; - if (term_pane.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 @@ -14265,7 +10344,7 @@ pub const Pardes = struct { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; - const pfx: i32 = if (pane.file != null) config.PREFIX_W else 0; + const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; // both ends of the sweep through the same last-frame map, so a // chord over wrapped rows cuts the text that was under it const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); @@ -14278,7 +10357,7 @@ pub const Pardes = struct { pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; - pane.pending = 0; + pane.normal.clear(); if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.nsel = 0; @@ -14295,7 +10374,7 @@ pub const Pardes = struct { const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; - file_pane.setContent(p, f, new); + panes.File.setContent(p, f, new); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { @@ -14309,45 +10388,7 @@ pub const Pardes = struct { pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); - } else p.normalPaste(pane, true); // terminal: splice run text at the cursor - } - - // ---- layout surgery ---- - - pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { - for (0..p.ncol) |c| { - for (0..p.col_n[c]) |k| { - if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k }; - } - } - return null; - } - - fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void { - var k = p.col_n[c]; - while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1]; - p.col_terms[c][idx] = id; - p.col_n[c] += 1; - } - - /// Remove from the layout. An emptied column hands its width to a neighbor - /// (else every surviving column reflows sideways) before shifting down. - pub fn layoutRemove(p: *Pardes, id: usize) void { - const f = p.layoutFindTerm(id) orelse return; - const c = f.col; - var k = f.idx; - while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1]; - p.col_n[c] -= 1; - if (p.col_n[c] == 0) { - if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_terms[j] = p.col_terms[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - } - p.ncol -= 1; - } + } else p.pasteText(pane, p.yank orelse return, true, 1); } /// Newtty: a shell in the caller's directory, raw from the first frame, @@ -14358,188 +10399,20 @@ pub const Pardes = struct { const nt = p.newShell(free, paneDir(src)) catch return; nt.greet = true; nt.mode = .tty; - const parent = p.splitParent(from); - const f = p.layoutFindTerm(parent).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(parent, nt); + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, parent, nt); p.active = free; } - /// Joincol: fold the active pane's column into the one on its right, - /// carrying its panes and width across. Inert without a right neighbour. - pub fn joinCol(p: *Pardes) void { - const f = p.layoutFindTerm(p.active) orelse return; - const c = f.col; - if (c + 1 >= p.ncol) return; - const dst = c + 1; - p.col_weight[dst] +|= p.col_weight[c]; - for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k]; - p.col_n[dst] += p.col_n[c]; - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_terms[j] = p.col_terms[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - } - p.ncol -= 1; - } - - /// Whether `source_id` can donate half its width to a new column. This is - /// public so callers that must create a pane first can reject before that - /// creation emits any native-side work. - pub fn layoutCanSplitColumn(p: *Pardes, source_id: usize) bool { - if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; - const source = p.layoutFindTerm(source_id) orelse return false; - // Refresh derived widths: public layout surgery may be chained between - // syncs, and a cached width must never admit a now-too-narrow split. - p.computeGeom(); - if (p.col_w[source.col] < config.MINW * 2) return false; - - const weight = p.col_weight[source.col]; - if (weight >= 2 and weight % 2 == 0) return true; - for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) - return false; - return weight > 0; - } - - /// Put `id` in a fresh column immediately beside `source_id`, taking the - /// new column's width only from the source column. `before` is used by a - /// first document, which belongs to the left of the shell that opened it; - /// Newcol and Alt-c put the new column on the right. - /// - /// `id == source_id` moves one pane out of a stack. Its vertical weight is - /// absorbed before removal, while the column's horizontal weight remains - /// in place to be split. In either form the total column weight is exactly - /// unchanged, so every unrelated column keeps both its width and its x. - pub fn layoutSplitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { - if (id >= MAX_PANES or p.panes[id] == null) return false; - if (!p.layoutCanSplitColumn(source_id)) return false; - const source = p.layoutFindTerm(source_id) orelse return false; - const source_col = source.col; - var old_weight = p.col_weight[source_col]; - const needs_rebase = old_weight < 2 or old_weight % 2 != 0; - if (needs_rebase) old_weight *= 2; - if (id == source_id) { - if (p.col_n[source_col] <= 1) return false; - p.absorbVWeight(id); - p.layoutRemove(id); - } else if (p.layoutFindTerm(id) != null) return false; - - // A pathological hand-written dump can restore a positive proportion - // below one fixed-point quantum. Rebase every column together before - // splitting; ratios and therefore geometry remain identical. - if (needs_rebase) { - for (0..p.ncol) |column| p.col_weight[column] *= 2; - } - const source_weight = old_weight / 2; - const new_weight = old_weight - source_weight; - p.col_weight[source_col] = source_weight; - const c = source_col + @intFromBool(!before); - var j = p.ncol; - while (j > c) : (j -= 1) { - p.col_terms[j] = p.col_terms[j - 1]; - p.col_n[j] = p.col_n[j - 1]; - p.col_weight[j] = p.col_weight[j - 1]; - } - p.col_weight[c] = new_weight; - p.col_terms[c][0] = id; - p.col_n[c] = 1; - p.ncol += 1; - return true; - } - - /// Snap every pane in column `c` to its on-screen row count so later - /// weight edits move ONLY the panes they name: computeGeom rounds - /// round(avail*w/vsum) per pane, and with fractional weights a split or - /// absorb elsewhere in the column can jiggle a bystander by a row. - fn snapColWeights(p: *Pardes, c: usize) void { - for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); - } - } - - /// Hand a dying pane's rows to ONE sibling (the pane above, or below for - /// the topmost) so the rest of the column keeps its sizes bit-identical — - /// the deletion mirror of splitBelow. Call while `id` is still in the - /// layout, with rects current. - pub fn absorbVWeight(p: *Pardes, id: usize) void { - const f = p.layoutFindTerm(id) orelse return; - if (p.col_n[f.col] <= 1) return; - p.snapColWeights(f.col); - // the pane above, but never a result list: walking past a stack of them - // lands on the pane that spawned it, so those keep their heights and - // only the spawner grows. The topmost pane has only what is below it. - var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; - var k = f.idx; - while (k > 0) : (k -= 1) { - sib = p.col_terms[f.col][k - 1]; - if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; - } - if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); - } - - /// The parent a new pane splits from must be tall enough that splitBelow - /// leaves the NEW pane at least 2 body rows (the parent keeps tag+1 row, - /// the new pane needs tag+2). A too-short choice is swapped for a - /// qualifying pane (same column first, keeping the split local), else the - /// tallest pane anywhere. - fn splitParent(p: *Pardes, want: usize) usize { - const need = 2 * BOX_H + 3; - if (p.rects[want].h >= need) return want; - if (p.layoutFindTerm(want)) |f| for (0..p.col_n[f.col]) |k| { - if (p.rects[p.col_terms[f.col][k]].h >= need) return p.col_terms[f.col][k]; - }; - var tallest = want; - for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { - const pid = p.col_terms[c][k]; - if (p.rects[pid].h >= need) return pid; - if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; - }; - return tallest; - } - - /// Open a window BELOW `src` (acme-style) without rebalancing the column: - /// shrink ONLY src to its content height (cursor row kept visible) and hand - /// the freed rows to `nw` — together they fill src's old slot and the other - /// panes keep their sizes bit-identical (weights snap to row counts). - fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { - const src = p.panes[src_id] orelse return; - const src_h = p.rects[src_id].h; - const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; - const cur: u16 = if (!src.isTerminal()) body / 2 else term_pane.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) - const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); - if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { - const pid = p.col_terms[f.col][k]; - if (p.panes[pid]) |pp| if (pp != nw) { - pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); - }; - }; - src.vweight = @floatFromInt(BOX_H + keep); - nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); - // a non-doc buffer is worth exactly its own text: a three-hit +Search - // is four rows, not half the source. Nothing else can want the rows, - // so they go straight back to the pane they were taken from. - if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { - // trimmed: every row ends in a newline, and the empty line after - // the last one is not a result - const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); - if (want < nw.vweight) { - src.vweight += nw.vweight - want; - nw.vweight = want; - } - }; - } - /// when a doc lands in `col`, a lone pristine shell there is clutter — drop /// it; absorbVWeight hands its space to the doc fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { var n_tty: usize = 0; var tty_id: usize = 0; for (0..p.col_n[col]) |k| { - const cid = p.col_terms[col][k]; + const cid = p.col_panes[col][k]; if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; @@ -14549,27 +10422,16 @@ pub const Pardes = struct { 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 term_pane.gridCursor(tt).y != 0 or - term_pane.scrollbar(tt).total > tt.rows) return; - p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows - p.absorbVWeight(tty_id); - p.layoutRemove(tty_id); - p.deinitPane(tt); + 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); + layout.removePane(p, tty_id); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } - /// a terminal already in `dir` and still at its prompt, else a fresh shell - /// there at the bottom of the rightmost column. Backs middle-click send - /// from a file pane. Does NOT focus (execute keeps you where you were; look - /// focuses). - /// - /// A terminal whose tty is TAKEN (vim, a pager, an agent) is not a match for - /// its own cwd: it cannot run a command line, so the scan keeps going and - /// spawns rather than pretending it found somewhere to type. The directory - /// is compared FIRST because that comparison is free and the occupancy - /// question costs a walk through /proc — a window full of shells in other - /// directories is not worth one syscall. fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; @@ -14580,11 +10442,11 @@ pub const Pardes = struct { nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; if (p.col_n[rc] > 0) { - const src = p.splitParent(p.col_terms[rc][p.col_n[rc] - 1]); - const f = p.layoutFindTerm(src).?; - p.splitBelow(src, nt); - p.layoutInsert(f.col, f.idx + 1, free); - } else p.layoutInsert(rc, p.col_n[rc], free); + const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); + const f = layout.findPane(p, src).?; + layout.splitBelow(p, src, nt); + layout.insert(p, f.col, f.idx + 1, free); + } else layout.insert(p, rc, p.col_n[rc], free); return free; } @@ -14595,25 +10457,27 @@ pub const Pardes = struct { errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/Tutor"); errdefer p.gpa.free(path); + const history = try panes.File.History.create(p.gpa); + errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content }; + pane.file = .{ .path = path, .content = content, .history = history }; pane.cur_pinned = true; return pane; } - /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): - /// swap the tty_only boot pane for a file pane holding `content` verbatim - /// — file_pane.open minus the disk read (cases carry their buffer inline). - /// Unreachable from any shell; keep it dumb. - pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane { + pub fn setTestFile(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); - const path = try p.gpa.dupe(u8, "/hxcase.txt"); + const path = try p.gpa.dupe(u8, "/test.txt"); errdefer p.gpa.free(path); - p.deinitPane(p.panes[0].?); + 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); + errdefer p.gpa.destroy(pane); + try p.deinitPane(p.panes[0].?); p.panes[0] = null; - const pane = try p.newDocPane(0); - pane.file = .{ .path = path, .content = copy }; + pane.file = .{ .path = path, .content = copy, .history = history }; + p.installPane(0, pane); pane.cur_pinned = true; p.active = 0; p.sync(); @@ -14623,40 +10487,29 @@ pub const Pardes = struct { fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { if (comptime !pdf_enabled) return; if (pane.pdf == null) return; - var state = paneNormalState(pane); - // The way OUT of a PDF, because plain Escape below is the document's - // own cancel (selection, search overlay) and never moves focus. Before - // the parser: `config.escape` matches a shifted Escape too, since shift - // is consulted only where a binding asks for it. if (hit(key, config.leave_pane)) { - state.clear(); - putPaneNormalState(pane, state); + pane.normal.clear(); pane.select = false; return p.runBuiltin(.Last, p.active, "", null); } - // A PDF has no body character to find, so its bare `f` is the direct - // document-outline door. Prefix continuations still go through the - // shared parser, and text/terminal panes retain `f` unchanged. - if (state.prefix == .none and isPrefix(key, 'f')) { - state.clear(); - putPaneNormalState(pane, state); + if (pane.normal.prefix == .none and isPrefix(key, 'f')) { + pane.normal.clear(); return p.runBuiltin(.PdfSections, p.active, "", null); } - const parsed = normal_input.parse(&state, normalInput(key)); - putPaneNormalState(pane, state); + const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { - const result = pdf_pane.applyNormal( + const result = panes.Pdf.applyNormal( &pane.pdf.?, p.pdf_gpa, semantic, p.native_images, .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h }, - pdf_pane.paneViewport(p, pane), - pdf_pane.paneGeometry(p, pane), + panes.Pdf.paneViewport(p, pane), + panes.Pdf.paneGeometry(p, pane), ); - if (result.page_changed) pdf_pane.resetPageChrome(pane); + if (result.page_changed) panes.Pdf.resetPageChrome(pane); switch (result.host) { .none => {}, .leader => { @@ -14667,7 +10520,7 @@ pub const Pardes = struct { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, - .search => p.startSearch(pane, config.search_marker), + .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } @@ -14675,71 +10528,37 @@ pub const Pardes = struct { } } - /// stack a fresh doc pane at the top of the LEFT column (acme convention: - /// files left, terminals right), halving ONLY the old top pane's slot so - /// the rest of the column keeps its sizes; then evict a leftover pristine - /// shell. The placement of last resort — a first doc normally takes a - /// column of its own (placeDoc), and this only runs when the column bar - /// is full. fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; - if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { - p.snapColWeights(lc); - const h = p.rects[p.col_terms[lc][0]].h; + if (p.col_n[lc] > 0) if (p.panes[p.col_panes[lc][0]]) |top| { + layout.snapColWeights(p, lc); + const h = p.rects[p.col_panes[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; - p.layoutInsert(lc, 0, free); + layout.insert(p, lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- - /// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal - /// cursor there (`at.col` likewise, 0 = line start). `landing` says how far - /// the view may MOVE to show it: `.center` recenters a file on the line and - /// reveals a PDF's page — a look target, a `:NN`, a search hit, where the - /// context around the destination is the whole point of going there — while - /// `.keep` leaves the view alone and lets `ensureCursorVisible` do the least - /// that shows the cursor, usually nothing at all. A terminal has no recenter - /// to skip, so `landing` does not gate it — but it is not therefore free of - /// movement: a modal cursor PINNED high in the scrollback still pulls the - /// view up to it, which is `ensureCursorVisible` keeping its promise and the - /// reason `Last` records `line = 0` for an unpinned shell. Both look targets - /// that name a live pane land here — a path a pane already holds, and - /// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below). pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; - if (hasPdf(pane)) { - // A page reveal IS this pane's view, so `.keep` is simply not doing - // it. Not a formality: a reveal of the page you are already on still - // sets `document_scroll_y` to `page_starts[page]`, so returning to a - // PDF threw away the offset WITHIN the page you were reading. - // - // Read that literally — under `.keep` a PDF's page is not restored - // AT ALL, and the spot's line is a page. That only shows when - // something moved the pane while you were away, and something can: - // the wheel scrolls the pane under the POINTER, not the active one. - // Then Esc leaves the PDF on the page the wheel reached rather than - // the one the jumplist recorded, which is the answer a RETURN wants - // and not the answer a jump wants — so Ctrl-o and Ctrl-i, which - // centre, are still how you reach the recorded page. + pane.normal.clear(); + if (pane.hasPdf()) { if (landing == .keep) return; if (comptime pdf_enabled) { const pv = &pane.pdf.?; - if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane); + if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); } return; } if (at.line == 0) return; if (pane.file) |*f| { - // The clamp holds either way: a stale jump naming a line past the - // end must not land the cursor there just because the view is not - // moving. - if (at.line > file_pane.nlines(p.gpa, f)) return; + if (at.line > panes.File.nlines(p.gpa, f)) return; if (landing == .center) { const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { @@ -14748,11 +10567,6 @@ pub const Pardes = struct { } } } - // Land the modal cursor on the target line. Under `.center` that keeps - // ensureCursorVisible agreeing with the recenter — a stale cursor would - // yank the view right back. Under `.keep` it IS the whole policy: no - // recenter ran, so the nudge below is the only thing that can move the - // view, and it moves it only far enough to show the cursor. pane.cur_row = @intCast(at.line - 1); pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.cur_pinned = true; @@ -14760,36 +10574,16 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } - /// Select the span a RANGED look word names (config.range_sep): the two - /// ends are block-cursor CELLS, so this is the same cellRange/setPaneRange - /// pair every motion writes back through — the cursor lands on the span's - /// last cell with the anchor on its first, which is where helix leaves you - /// after a search too. - /// - /// EXPLICIT, so the acme chords act on it like a v/x selection: the whole - /// point of `n` selecting a hit is being able to chord the match straight - /// into the next command. - /// - /// Everything clamps, because a range is a claim about a file that may - /// have changed underneath it: hxOff pins a row past the end to the last - /// line and a column past the end to that line's terminator, so a stale - /// row selects what is still there instead of crashing or highlighting - /// garbage. A whole-lines range (no end column) runs to the terminator by - /// asking for a column no line can have, which is helix's own `x`. fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; + const text = p.flatSurface(pane) catch return; // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a // 1-based zero is the same nothing an absent number is const acol: i32 = @intCast(at.col -| 1); const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); - const r = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); - setPaneRange(pane, pl, text, r, true); + const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); + pane.setRange(text, 0, r, true); } - /// focus the pane already loaded on `path` (exact match), if any: file - /// panes recenter on a :NN line like the look dedup always has, image - /// panes just focus. Returns false when no pane holds that path. fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; @@ -14809,50 +10603,19 @@ pub const Pardes = struct { return false; } - /// LOOK — the Look builtin's body, and so what a right click, an Enter and - /// the word `Look` all end at. Resolve `txt` against the panes' directories - /// and open (or focus) whatever it names; a word that names nothing is a - /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); - // ...and this pane is now the head of the n/N walk. Recorded HERE, at - // the one dispatcher every look reaches, so a right click, an Enter, a - // stepped result row and the word `Look` all count alike. p.noteLookSource(id); const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the - // pane that answers — looking at a thing means being SHOWN it, and a - // command's output is what there is to be shown. Looking at a - // DIRECTORY has always been exactly this (below: focus a shell there - // and make it `ls`); this is that rule spelled generally. if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } var realbuf: [4096]u8 = undefined; - // an already-loaded pane wins BEFORE any filesystem resolve: the - // web build has no fs (a look would otherwise be inert even for - // panes sitting in the session), and native gets the same dedup - // it always did, just without touching disk. Pane paths are - // canonical (realpath'd or dump-given), so match the word as-is - // here and joined onto each directory below. const pl = look.parsePathLine(trimmed); - if (comptime pdf_enabled) if (pdf_pane.lookSection(p, id, pl.path, pl.at)) return; + if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return; - // The word is resolved against the pane DIRECTORIES in access - // order: the pane the click came from FIRST — its answer is the - // one taken, so nothing that resolves today moves — then every - // other live pane, most recently focused first (the jump stack runs - // least-recent -> active, so it is that array backwards; the - // clicked pane is not always `active`, a right click does not - // focus). Only when ALL of them fail does the word fall through to - // the search below: a name you can read in one window is openable - // from any of them. Each attempt is a realpath + an open and - // shells share cwds constantly, so a seen-list holds every - // directory to one try; an absolute word — and `@pN`, which reads - // no directory at all — answers the same everywhere and stops - // after the first pass. var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; @@ -14870,51 +10633,27 @@ pub const Pardes = struct { if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| if (p.focusPaneByPath(j, pl.at)) return; } - found = look.resolve(txt, dir, &realbuf); + found = look.resolve(p, txt, dir, &realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } switch (found) { - // acme button-3: a word that names no file/dir is a search of - // the pane it was clicked in — exactly what `/` runs, and then a - // step onto a hit, so a click GOES somewhere and clicking again - // goes to the next one. Paths (src/a/b.rs:100) still resolve above - // and open; only the non-file case falls through here. A shell - // searches its scrollback like anything else, EXCEPT in tty - // mode, where the click belongs to the program on the other - // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; - // ...and then STEP it, which is the other half of button-3: a - // click does not merely LIST the hits, it goes to one — the - // one AFTER the word clicked, since runSearch armed the walk - // where the click put the cursor. Asking again is free: the - // same pattern refills its own list rather than opening a - // second, so clicking a word repeatedly walks its hits. p.runSearch(id, trimmed, .text, .cursor) catch |err| { p.reportError(id, "search", err); return; }; const at = pane.search_row; _ = p.searchStep(id, 1); - // past the last hit, back to the first: acme's search is a - // RING, and a step that could not move left the row where it - // was (searchStep clamps rather than wrapping, because n/N are - // also how `]d`/`[d` walk to the end of a list and stop). if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, - // `@p7:10:5`: pane 7, line 10, column 5 — how a search result - // points at a terminal or an output buffer, neither of which - // has a path. .pane => |t| p.focusPaneLine(t.id, t.at, .center), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { - // focus an existing terminal on this dir, else fork one below. - // A terminal whose tty is taken is not that terminal: `ls\r` - // typed into vim is `ls\r` typed into vim. for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.active = i; @@ -14922,26 +10661,20 @@ pub const Pardes = struct { return; }; } - // A LOOK WITH NOWHERE TO PUT THE ANSWER SAYS SO. All four - // slot checks in this function were a bare `orelse return`, - // which with one selection merely felt like a dead key and with - // several means "I opened nine of your fourteen and told you - // nothing". `NoPaneSlots` is the error output_pane.zig already - // raises for this, through the channel a test already pins. const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; - const src = p.splitParent(id); - const f = p.layoutFindTerm(src).?; - p.layoutInsert(f.col, f.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, id); + const f = layout.findPane(p, src).?; + layout.insert(p, f.col, f.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch |err| + const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); return; @@ -14949,22 +10682,12 @@ pub const Pardes = struct { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - // ...and WHY a file would not open, which `look.readFile` now - // distinguishes: permission denied, a pipe, too large. - const nt = file_pane.open(p, free, target.path, target.at.line) catch |err| + const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); - // center the target line: the pane's real body height only - // exists after placement, so lay out now and pull the - // scroll up by half a body (line 0 opens stay at the top) - p.computeGeom(); + layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; - // ...and only THEN select a range, restoring that scroll: - // setPaneRange keeps its cursor visible, and a pane this fresh - // has no true geometry yet for it to judge against (pane.rows - // is only refreshed in sync), so the centering just computed is - // the answer and ensureCursorVisible's is not. if (target.at.end_line != 0) { const centered = nt.file.?.scroll; p.selectSpan(nt, target.at); @@ -14974,52 +10697,27 @@ pub const Pardes = struct { .image => |target| { if (p.focusPaneByPath(target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = image_pane.create(p, free, target.path, &.{}) catch |err| + const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); }, } } - /// how deep `execute` may re-enter itself. Nothing can reach this today: - /// every door back in strips at least one word (`Exec X` -> `X`) or one - /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly - /// shrinks and a cycle cannot close — executing the bare word `Exec` runs - /// out of argument immediately. The counter is here so that a syntax added - /// later which does NOT shrink (an alias, a macro) stops instead of hanging - /// the editor, and the ceiling is small because a human nesting eight deep - /// has made a different mistake. const max_exec_depth = 8; - /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab - /// and the word `Exec` all end at. A builtin's NAME runs the builtin; - /// anything else is a command line typed at a shell. Returns the pane it - /// was typed into, which is what Look focuses and an execute deliberately - /// does not. pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { const pane = p.panes[id] orelse return null; const cmd = commandText(txt); if (cmd.len == 0) return null; - // Before the builtin dispatch, and only at depth zero: `Exec ls` comes - // back through here as `ls` (executeBuiltinLine holds the depth), and - // one Tab is one thing the hand did, however many words it unwraps to. if (p.exec_depth == 0) p.noteHaptic(.exec); if (p.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; - // Anything not in the builtin vocabulary is a command line typed at - // a shell. Startup config calls executeBuiltinLine directly and never - // reaches this fallback. - // A terminal runs in itself — as long as its tty is still the prompt we - // forked. Every document kind — real/output file, image, or PDF — and a - // terminal currently held by a full-screen program run in a terminal for - // their directory (an existing prompt when possible, otherwise a freshly - // forked shell). In particular, never emit a PTY write addressed to an - // image's no-PTY pane slot, and never type a command line at vim. const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; - term_pane.padOutputBelowEdits(p, dst); - if (term_pane.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { + panes.Terminal.padOutputBelowEdits(p, dst); + if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { p.reportError(id, "queue command", err); return dst; }) return dst; @@ -15036,20 +10734,9 @@ pub const Pardes = struct { return cmd; } - /// Parse and dispatch exactly one builtin command. This is the canonical - /// builtin path used both by ordinary Exec and by startup configuration; - /// unlike execute(), it deliberately has no external-shell fallback. - /// False means blank, malformed, unknown, or recursion-limited. pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const cmd = commandText(txt); if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; - // The builtins that take an ARGUMENT match their name with a TAIL: - // `Restore `, `Find `, `Grep `, `Rename `, - // `WsSymbols `, `Theme `, `Font ` (gui only), and the - // two verbs themselves — `Look `, `Exec `, which is what - // makes `` @`Look .` `` nest (the tail goes straight back through - // here). Every other name must match WHOLE, so `Kill foo` is a shell - // command and not Kill. const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; @@ -15068,17 +10755,6 @@ pub const Pardes = struct { while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } - /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads - /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail - /// after the name, which is why Grep and Find run straight away when there - /// is one instead of asking, and which for Look and Exec is the whole - /// operand. A gesture that points at a word (a click, an Enter) passes it - /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The - /// leader passes "" and null: a key path names a builtin, never an - /// argument. The topbar builtins are global; the pane-scoped ones (Save, - /// Del, Delcol, the image toggles, and the window group, which moves focus - /// relative to `id`) act on `id`. The null-pane check is the one guard - /// every builtin used to share, so it stays here rather than in each. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; @@ -15086,10 +10762,8 @@ pub const Pardes = struct { builtins.registry.dispatch(b, c); } - /// Generated setting builtins converge here. The descriptor is compile- - /// time data but the state is ordinary owned data; no vtable or callback - /// layer sits between the command enum and these fields. - pub fn applySettingBuiltin(p: *Pardes, setting: runtime_cfg.Setting, arg: ?[]const u8) void { + pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { + const previous_colors = p.settings.colors; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; switch (setting.action) { @@ -15105,109 +10779,66 @@ pub const Pardes = struct { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); const matches = fonts.list(p.scratch.allocator(), want); if (matches.len == 0) return; - if (!runtime_cfg.requestFont(&p.settings, matches[0].path, matches[0].name)) return; + if (!p.settings.requestFont(matches[0].path, matches[0].name)) return; p.font_request_taken = false; }, - else => _ = runtime_cfg.applySimple(&p.settings, setting, arg), + else => _ = p.settings.apply(setting, arg), + } + if (p.settings.colors != previous_colors) { + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |*file| file.syntax_dirty = true; + } } const transition_changed = setting.action == .transition and p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (transition_changed or tagline_metrics_changed) { - // The backend may still show the last acknowledged sample. Keep - // input inert until it has replaced that sample with canonical - // pixels instead of merely forgetting the producer-side tracks. - // A tagline-size change also invalidates the raster metrics used - // by a frozen old frame; snapping is the only honest old/new pair. p.abandonPanelAnimations(); } } - /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a - /// document: it is the result list belonging to the pane that asked for it, - /// so it never claims a column and is never anyone else's split parent — it - /// lands right below `from_id`, be that a shell, a file or another list, - /// and `from_id` alone pays the rows (several lists just stack there). - /// A real doc joins the docs: any doc already open is the split parent (the - /// source if it IS one, else the one most recently worked in) and the - /// newcomer lands right below it, so docs share a column. The FIRST doc of - /// the session instead gets a column of its own on the left (acme: files - /// left, shells right). Only the calling/source column donates half its - /// width; every other column keeps its boundary. A full column bar, or a - /// result list whose source died, stacks into the leftmost. - /// A new file opens a column only when the split leaves both sides wide - /// enough to read; otherwise it stacks as a pane. Halving is exact, so the - /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells. - fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool { - const f = p.layoutFindTerm(source_id) orelse return false; - p.computeGeom(); - return p.col_w[f.col] >= min_cells * 2; - } - pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc + const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { + if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { src_id = from_id; }; - // Opened from somewhere that is NOT a doc (a shell, a results list): - // the file joins the column it was last being READ in, which is the - // newest doc on the jump stack. The stack already IS that record — it - // is where the keyboard has been — so nothing new is remembered here; - // asking it is the whole change. What this replaces was the first doc - // in slot order, i.e. pane-id ALLOCATION order, so which column your - // file landed in depended on how the session had handed out ids rather - // than on where you were working. - // - // The answer wanted is the COLUMN, and it stays right once the pane - // itself is closed: trackJump compacts dead entries out, so the walk - // falls through to the next doc remembered in that same column. The - // serial test is the one trackJump uses — slots are reused, so an entry - // whose pane has been replaced names a pane that is gone, not the - // newcomer sitting in its slot (`free`, this very pane, among them). if (doc and src_id == null) { var n = p.njumps; while (n > 0) : (n -= 1) { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; - if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { + if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { src_id = j.pane; break; } } } - // A doc no jump remembers is still a doc: a restored session focuses - // one pane, not each, and the stack is finite. The rule that a second - // doc never claims a second column outranks knowing where you were, so - // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { + if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { src_id = i; break; }; }; - // A result list belongs to its spawner: it lands directly BELOW it — - // on top of the lists already there — and its rows come out of the - // SPAWNER, never a bystander, so opening another list (or deleting - // one, see absorbVWeight) leaves every other pane's height untouched. - if (!doc) if (p.layoutFindTerm(from_id)) |sf| { - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays + if (!doc) if (layout.findPane(p, from_id)) |sf| { + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id) |sid| { - const src = p.splitParent(sid); - const sf = p.layoutFindTerm(src).?; - p.layoutInsert(sf.col, sf.idx + 1, free); - p.splitBelow(src, nt); + const src = layout.splitParent(p, sid); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, src, nt); p.active = free; return; } - if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) { - if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt); + if (doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100)) { + if (!layout.splitColumn(p, from_id, free, true)) return p.stackDocLeft(free, nt); p.active = free; return; } @@ -15219,7 +10850,7 @@ pub const Pardes = struct { pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); - var panes: [MAX_PANES]dump.Pane = undefined; + var dump_panes: [MAX_PANES]dump.Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; @@ -15227,32 +10858,25 @@ pub const Pardes = struct { const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); - // BY POINTER: `origin_arg` is a slice into the File's own inline - // buffer, and a by-value capture would leave it pointing at a - // stack copy that dies before the ZON is written. - var dp: dump.Pane = if (pane.file) |*f| try file_pane.dumpPane( + var dp: dump.Pane = if (pane.file) |*f| try panes.File.dumpPane( arena, pane, f, tag, body, scroll, - if (f.output) |o| output_pane.word(o.from) else "", + if (f.output) |o| panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", - ) else if (hasPdf(pane)) blk: { + ) else if (pane.hasPdf()) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; - // Keep the dump schema backwards-compatible: a raster-backed - // document rides the existing binary image record, while its - // `scroll` field is the zero-based PDF page. Restore inspects - // the extension and reconstructs the semantic PDF pane. - break :blk try image_pane.dumpPane(arena, pane, tag, body, pv.page, pv.path, &.{}); + break :blk try panes.Image.dumpPane(p, arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| - try image_pane.dumpPane(arena, pane, tag, body, scroll, iv.path, iv.raw) + try panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else - try term_pane.dumpPane(pane, arena, tag, body, scroll); + try panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; - panes[panes_len] = dp; + dump_panes[panes_len] = dp; panes_len += 1; } @@ -15261,7 +10885,7 @@ pub const Pardes = struct { var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { var ids_len: usize = 0; - for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_terms[c][k]]) |compact| { + for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; @@ -15274,13 +10898,16 @@ pub const Pardes = struct { } } + var mounts: [filesystem.max_mounts]dump.Mount = undefined; + for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: dump.State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .theme = p.theme().name, .columns = columns[0..columns_len], - .panes = panes[0..panes_len], + .panes = dump_panes[0..panes_len], + .mounts = mounts[0..p.fs.mounts.items.len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -15291,10 +10918,18 @@ pub const Pardes = struct { p.emit(.write_dump); } - /// Initialize from another instance's dump: panes reconstructed (terminals - /// by replaying their raw VT streams into fresh emulators), no spawns — - /// loaded terminals are dead replays, scrollable and selectable. + pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { + var opts = p.opts; + opts.cols = p.screen_w; + opts.rows = p.screen_h; + return initDump(p.gpa, opts, zon_bytes, p); + } + pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { + return initDump(gpa, opts, zon_bytes, null); + } + + fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; @@ -15305,6 +10940,11 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .fs = .{ + .socket_path = opts.ninep_identity.socket_path, + .tcp_address = opts.ninep_identity.tcp_address, + .quic_address = opts.ninep_identity.quic_address, + }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), @@ -15312,8 +10952,32 @@ pub const Pardes = struct { .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); - const st = try dump.readZon(gpa, zon_bytes, "load"); - defer dump.free(gpa, st); + if (previous) |old| { + p.next_serial = old.next_serial; + p.lsp_seq = old.lsp_seq; + p.pipe_seq = old.pipe_seq; + p.cell_pixels = old.cell_pixels; + p.native_images = old.native_images; + p.fs.socket_path = old.fs.socket_path; + p.fs.tcp_address = old.fs.tcp_address; + p.fs.quic_address = old.fs.quic_address; + } + for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); + p.opts.mounts = &.{}; + p.opts.ninep_identity = .{}; + var parsed = try dump.readZon(gpa, zon_bytes, "load"); + defer parsed.deinit(); + const st = parsed.value; + for (st.mounts) |mount| { + var already_mounted = false; + for (p.fs.mounts.items) |existing| { + if (!std.mem.eql(u8, mount.name, existing.name)) continue; + if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; + already_mounted = true; + break; + } + if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); + } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); @@ -15323,23 +10987,23 @@ pub const Pardes = struct { const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; - const restored = try term_pane.restore(p, src); + const restored = try panes.Terminal.restore(p, src); p.installPane(i, restored); - p.setCwd(i, t.cwd); + try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; break :terminal restored; }, - .file => try file_pane.restore(p, i, src), + .file => try panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; - if (pdf_pane.isPath(im.path)) { + if (panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try dump.decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { - if (pdf_pane.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { + if (panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); @@ -15354,15 +11018,17 @@ pub const Pardes = struct { const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); + const history = try panes.File.History.create(gpa); + errdefer gpa.destroy(history); const restored = try p.newDocPane(i); - restored.file = .{ .path = path, .content = content }; + restored.file = .{ .path = path, .content = content, .history = history }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); - p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); + p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } - break :restore_image try image_pane.restore(p, i, src); + break :restore_image try panes.Image.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); @@ -15374,14 +11040,14 @@ pub const Pardes = struct { const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); p.col_n[c] = @min(col.panes.len, MAX_PANES); - for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid; + for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); - p.panel_animation_enabled = true; + p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture return p; } @@ -15393,12 +11059,6 @@ pub const Pardes = struct { return; } const tail_class = tailClass(pane); - // Early dumps put tty mode in the live prefix itself. The cwd stored - // beside the replay stream is the stable half of that historical - // prefix; use it to recover both custom tails and defaults even though - // today's tag has only the mode box plus cwd. Check this before the - // current prefix because a not-yet-reported cwd is legitimately empty - // and therefore a prefix of every saved tag. if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; if (std.mem.startsWith(u8, src.tag, legacy)) @@ -15409,25 +11069,18 @@ pub const Pardes = struct { return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); if (src.kind != .image) return; const saved = src.image.?; - if (pane.image) |*state| if (image_pane.legacySavedPrefix(state, src.tag)) |legacy| + if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| return p.restoreTailAt(pane, src.tag, legacy, .generic); - if (pdf_pane.isPath(saved.path)) if (pdf_pane.legacySavedPrefix(saved.path, src.tag)) |legacy| + if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| p.restoreTailAt(pane, src.tag, legacy, .generic); } - /// Compatibility path for dumps without explicit tag_tail: recover what - /// followed the rendered prefix, upgrading every historical default while - /// retaining genuinely edited bytes. fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; const class = tailClass(pane); p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } - /// Which family of historical defaults a saved tail is read against. An - /// output buffer is its own class rather than a file: it wears the file - /// tail today, but the tail it was DUMPED with was the generic one, and a - /// real file must not inherit that recognition (see restoreTailAt). const TailClass = enum { generic, file, output, terminal }; fn tailClass(pane: *const Pane) TailClass { @@ -15436,11 +11089,6 @@ pub const Pardes = struct { return .generic; } - /// A dirty marker may be present in the rendered compatibility tag of an - /// untouched file. It is live prefix chrome, not a custom command tail; - /// consume it while recovering old dumps so it disappears after Save. Only - /// a pane with a file to be dirty AGAINST ever rendered one — a real file, - /// or the scratch that is becoming one. fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { if (class == .generic or class == .terminal) return live; if (!std.mem.startsWith(u8, saved_tag, live)) return live; @@ -15459,22 +11107,10 @@ pub const Pardes = struct { ) void { if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; - // A saved tag carries its layout gap, because the padding is real - // characters — and the pane it is restored into is very often a - // different width than the one it was dumped from. So compare what the - // tail SAYS and not where it sat: leading spaces are layout, never - // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, - // An output buffer wore the GENERIC default until Save reached it, - // and a terminal's has now been through three shapes; both upgrade - // from that family. The scratch is an output buffer that wore the - // FILE defaults all along (it was the one that could Save), so its - // row carries both. Recognition stays scoped per class: the - // generic default sitting on a real FILE is still text its owner - // typed and is still kept. .output => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail, @@ -15487,11 +11123,11 @@ pub const Pardes = struct { pane.tag_init = true; } - fn removePane(p: *Pardes, id: usize) void { + pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; - p.absorbVWeight(id); - p.layoutRemove(id); - p.deinitPane(pane); + try p.deinitPane(pane); + layout.absorbVWeight(p, id); + layout.removePane(p, id); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; @@ -15500,10 +11136,28 @@ pub const Pardes = struct { }; } - /// where focus falls when the active pane closes: the most recently - /// focused pane still alive (else any live one). Null = nothing left. - /// Walks the jump stack newest-first, so it answers exactly what it always - /// did — a pane's newest entry sits where the old MRU put the pane. + pub fn removeColumn(p: *Pardes, id: usize) !void { + const place = layout.findPane(p, id) orelse return; + var ids: [MAX_PANES]usize = undefined; + var parents: [MAX_PANES]*Pane = undefined; + const count = p.col_n[place.col]; + for (0..count) |i| { + ids[i] = p.col_panes[place.col][i]; + parents[i] = p.panes[ids[i]].?; + } + try p.detachCwds(parents[0..count]); + for (ids[0..count], parents[0..count]) |closed, pane| { + p.retirePane(pane); + layout.removePane(p, closed); + p.panes[closed] = null; + } + if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { + p.quit = true; + p.emit(.quit); + return; + }; + } + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { @@ -15517,27 +11171,6 @@ pub const Pardes = struct { return null; } - /// THE PUSH RULE, and the only place it is written down. - /// - /// A location is worth remembering when you cannot see it any more: focus - /// ended this update in a DIFFERENT pane, or more than a bodyful of rows - /// away in the same one. Anything closer is the cursor strolling, and the - /// current entry just follows it — so h/j/k/w/b never grow the list, while - /// a goto-line, a search hit, a goto-definition and every focus change do. - /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the - /// question those commands are a proxy for, and needs no list.) - /// - /// It is read HERE, once per update, and nowhere else: what a call site - /// does transiently is invisible, which is what keeps n/N over a results - /// buffer — which focuses each hit and comes straight back — from pushing - /// two entries per keystroke. That transparency is the whole reason the - /// rule lives in sync() rather than at the sites that move focus, which is - /// where the heuristics used to be scattered. - /// The same PLACE, which is the one question the push rule asks: the same - /// live pane, and near enough within it that the cursor was only strolling. - /// Distance is a bodyful because that is what "you cannot see it any more" - /// means on a screen. A location with no line — an unpinned shell, whose - /// cursor belongs to the program — has no distance to be at. fn samePlace(p: *const Pardes, a: Loc, b: Loc) bool { if (a.pane != b.pane or a.serial != b.serial) return false; if (a.line == 0 or b.line == 0) return true; @@ -15546,9 +11179,6 @@ pub const Pardes = struct { } fn trackJump(p: *Pardes) void { - // dead entries first, in one compacting pass. A slot is reused, so the - // test is the SERIAL: an entry whose pane has been replaced names a - // pane that no longer exists, not the newcomer sitting in its slot. var w: usize = 0; var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { @@ -15562,13 +11192,6 @@ pub const Pardes = struct { p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; - // An UNPINNED cursor belongs to the program on the other end of the - // pty, not to you, so such a pane is remembered as a place and not as - // a spot: line 0 is the "no line" focusPaneLine already understands, - // and going back there focuses the shell without dragging its view up - // to scrollback row 0. It also has no line to be FAR from, which is - // what keeps the first keypress in a shell (which pins the cursor - // wherever the prompt is) from reading as a jump. const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, @@ -15582,22 +11205,6 @@ pub const Pardes = struct { // a new jump made from the middle of the list drops everything ahead of // it, the way vim's does: the future you did not take is not history. if (p.njumps > 0) p.njumps = p.jcur + 1; - // ...and neither is a hop STRAIGHT BACK to the entry under this one. - // That is not two jumps, it is the same two places again: Esc between a - // doc and its shell, `SPC w k` / `SPC w j`, clicking back and forth. - // Appending would grow the stack by one per press until the ping-pong - // is the only thing it remembers — sixty-four presses and every older - // place is gone. - // - // So SWAP the two instead of appending, rather than the other obvious - // move of leaving them alone and walking jcur back down onto the older - // one. The stack has a second job: it is also the focus history, and - // prevFocus, Last and the look order all read it backwards on the - // promise that it "runs least-recent -> active". Parking jcur mid-array - // leaves the pane you are IN somewhere other than the top and quietly - // breaks all three — a look would resolve against the directory of the - // pane you just left before the one you are in. Swapping keeps the - // promise, keeps the length, and leaves Ctrl-o stepping out past both. if (p.njumps >= 2 and p.samePlace(p.jumps[p.njumps - 2], now)) { p.jumps[p.njumps - 2] = p.jumps[p.njumps - 1]; p.jumps[p.njumps - 1] = now; @@ -15613,18 +11220,6 @@ pub const Pardes = struct { p.njumps += 1; } - /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into - /// the stack and go to what it names. Nothing is pushed and nothing is - /// dropped — walking history is not making it — and trackJump agrees, - /// because after the move the live spot IS `jumps[jcur]` again. - /// - /// `.center` where `Last` keeps the view, and the asymmetry is structural - /// rather than arbitrary: `Last` only ever CROSSES panes, so the pane it - /// lands on already holds the view you left it with. This may land in the - /// SAME pane, where there is no such view to keep — a long in-file jump - /// would arrive on the very top or bottom row with `scroll_off` lines of - /// context on one side. Helix splits the same pair the same way: its - /// jumplist centres, its buffer switch does not. pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; if (p.njumps == 0 or next < 0 or next >= p.njumps) return; @@ -15637,42 +11232,37 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { p.reapPanes(); - p.computeGeom(); - p.syncPanelAnimations(); + layout.compute(p); + p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; - if (comptime terminal_panes) if (pane.reply_len > 0) { + if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; - while (off < pane.reply_len) { - const n = @min(pane.reply_len - off, 64); - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } }); + while (off < state.reply_len) { + const n = @min(state.reply_len - off, 64); + p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(state.reply[off .. off + n]) } }); off += n; } - pane.reply_len = 0; + state.reply_len = 0; }; const r = p.rects[id]; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row - // a pane shrunk to just its tag keeps its last real grid: no - // pty/vt reflow while the body is hidden, so re-enlarging brings - // it back exactly as it was if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { - term_pane.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.cols = cols; pane.rows = rows; + if (pane.file) |*file| file.syntax_dirty = true; } - term_pane.releasePendingCommandIfReady(p, id, pane); - // Greet only after the real size AND OSC 133 B: arbitrary startup - // output (or OSC A plus a prompt drawn in pieces) does not prove - // readline owns echo, and injecting there leaves `ls` unmarked. - if (pane.greet and pane.isTerminal() and p.resize_count > 0 and term_pane.promptInputReady(pane)) { + panes.Terminal.releasePendingCommandIfReady(p, id, pane); + if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; @@ -15680,282 +11270,8 @@ pub const Pardes = struct { } } - fn panelBox(rect: Rect) panel_animation.Box { - return .{ - .x = @floatFromInt(rect.x), - .y = @floatFromInt(rect.y), - .w = @floatFromInt(rect.w), - .h = @floatFromInt(rect.h), - }; - } - - /// Turn one committed layout into backend-neutral transition records. - /// Layout remains authoritative and takes effect immediately; these tracks - /// are presentation data only, so disabling an effect cannot strand stale - /// geometry or alter hit testing. - fn syncPanelAnimations(p: *Pardes) void { - const initializing = !p.layout_snapshot_ready or !p.panel_animation_enabled; - if (initializing or p.snap_panel_layout_once) { - const had_presented_layout = p.layout_snapshot_ready; - p.layout_snapshot = @splat(null); - p.panel_tracks = @splat(null); - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - // Resize/direct-manipulation snaps still wait for their backend - // presentation acknowledgement. Clearing here would make input - // follow canonical geometry while the last submitted pixels were - // still animated. Initialization has no prior frame to preserve. - if (initializing) { - p.presented_panel_tracks = @splat(null); - p.npresented_closing_panel_tracks = 0; - } - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - p.layout_snapshot_ready = true; - p.snap_panel_layout_once = false; - if (had_presented_layout and p.panel_presentation_ready) - p.panel_presentation_pending = true; - return; - } - - const effect = p.settings.panel_transition; - if (effect.needsPreviousGrid() and (p.panel_diff_pending or p.panel_diff_ready)) { - var second_change = false; - for (p.panes, p.layout_snapshot, 0..) |slot, snapshot, id| { - const pane = slot orelse { - second_change = second_change or snapshot != null; - continue; - }; - const target = panelBox(p.rects[id]); - second_change = second_change or snapshot == null or - snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); - } - if (second_change) { - // One frozen old grid cannot honestly describe two overlapping - // generations. Snap rapid layout churn instead of rewinding a - // new opener or sliding stale survivor cells as a tombstone. - p.abandonPanelAnimations(); - p.layout_snapshot = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - return; - } - } - var changed = false; - var animated_change = false; - for (p.panes, 0..) |slot, id| { - const pane = slot orelse { - if (p.layout_snapshot[id]) |old| { - changed = true; - if (effect.lifecycleOnly()) { - if (p.appendClosingPanelTrack(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = panel_animation.closingBox(effect, old.box), - })) animated_change = true; - } - } - p.layout_snapshot[id] = null; - p.panel_tracks[id] = null; - // Never let pixels from a dead pane address a reused slot. - p.presented_panel_tracks[id] = null; - continue; - }; - const target = panelBox(p.rects[id]); - const previous = p.layout_snapshot[id]; - p.layout_snapshot[id] = .{ .serial = pane.serial, .box = target }; - - if (effect == .off) { - changed = changed or previous == null or previous.?.serial != pane.serial or - !previous.?.box.eql(target); - p.panel_tracks[id] = null; - continue; - } - if (previous) |old| { - if (old.serial == pane.serial and old.box.eql(target)) continue; - const same_lifetime = old.serial == pane.serial; - const prior = if (p.panel_tracks[id]) |track| - if (track.serial == pane.serial and track.active()) track else null - else - null; - if (effect.lifecycleOnly() and same_lifetime) { - // A vertical lifecycle transition deliberately leaves - // every survivor at canonical geometry. A still-opening - // lifetime remains an opener when another layout commit - // retargets it; an established survivor gets no record. - if (prior) |active| if (active.phase == .opening) { - var next = active; - next.from = panel_animation.openingBox(effect, target, p.screen_w); - next.to = target; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - continue; - }; - p.panel_tracks[id] = null; - changed = true; - continue; - } - if (effect.lifecycleOnly() and !same_lifetime) { - _ = p.appendClosingPanelTrack(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = panel_animation.closingBox(effect, old.box), - }); - } - const shown = if (p.presented_panel_layout[id]) |snapshot| - if (snapshot.serial == pane.serial) snapshot.box else null - else - null; - const from = if (!same_lifetime) - panel_animation.openingBox(effect, target, p.screen_w) - else if (shown) |box| - box - else if (p.panel_presentation_ready and prior != null) - prior.?.from - else - old.box; - const next: panel_animation.Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = if (!same_lifetime or - (prior != null and prior.?.phase == .opening)) .opening else .moving, - .effect = effect, - .from = from, - .to = target, - }; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - } else { - const next: panel_animation.Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = .opening, - .effect = effect, - .from = panel_animation.openingBox(effect, target, p.screen_w), - .to = target, - }; - p.panel_tracks[id] = next; - changed = true; - animated_change = true; - } - } - if (animated_change and effect.needsPreviousGrid()) { - p.panel_diff_pending = true; - p.panel_diff_ready = false; - } - if (changed and p.panel_presentation_ready) p.panel_presentation_pending = true; - } - - fn appendClosingPanelTrack(p: *Pardes, track: panel_animation.Track) bool { - std.debug.assert(track.phase == .closing); - const baseline = p.presented_cells_layout[track.pane] orelse return false; - if (!p.presented_cells_valid or baseline.serial != track.serial or - !baseline.box.eql(track.from)) return false; - if (p.nclosing_panel_tracks == p.closing_panel_tracks.len) { - // Bounded presentation history: under pathological delete/reuse - // churn, retire the oldest (and therefore furthest-progressed) - // tombstone rather than retaining a pane or allocating per Del. - std.mem.copyForwards( - panel_animation.Track, - p.closing_panel_tracks[0 .. p.closing_panel_tracks.len - 1], - p.closing_panel_tracks[1..], - ); - p.nclosing_panel_tracks -= 1; - } - p.closing_panel_tracks[p.nclosing_panel_tracks] = track; - p.nclosing_panel_tracks += 1; - return true; - } - - fn advancePanelAnimations(p: *Pardes) void { - for (&p.panel_tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - track.frame +|= 1; - if (!track.active()) slot.* = null; - } - var out: usize = 0; - for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |value| { - var track = value; - track.frame +|= 1; - if (!track.active()) continue; - p.closing_panel_tracks[out] = track; - out += 1; - } - p.nclosing_panel_tracks = out; - } - - fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { - if (total == 0) return 0; - const pixels = (@as(u128, width) * prefix + total / 2) / total; - return @intCast(@min(@as(u128, width), pixels)); - } - - pub fn computeGeom(p: *Pardes) void { - if (p.ncol == 0) return; - var wsum: u128 = 0; - for (0..p.ncol) |c| wsum += p.col_weight[c]; - if (wsum == 0) wsum = 1; - - // Round cumulative boundaries, not each width independently. Splitting - // one weight W into A+B=W then leaves every boundary before A and - // after B bit-identical, at every screen width; independent rounding - // can move a later column by one cell even though its own weight and - // the total did not change. - var prefix: u128 = 0; - for (0..p.ncol) |c| { - const last = c + 1 == p.ncol; - const x = columnBoundary(p.screen_w, prefix, wsum); - prefix += p.col_weight[c]; - const end: u16 = if (last) - p.screen_w - else - columnBoundary(p.screen_w, prefix, wsum); - const cw = end -| x; - p.col_x[c] = x; - p.col_w[c] = cw; - - var vsum: f32 = 0; - for (0..p.col_n[c]) |k| { - if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight; - } - if (vsum <= 0) vsum = 1; - - var y: u16 = TOPBAR_H; - const avail_h = p.screen_h -| TOPBAR_H; - for (0..p.col_n[c]) |k| { - const id = p.col_terms[c][k]; - const pane = p.panes[id] orelse continue; - const lastk = k + 1 == p.col_n[c]; - const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; - // every pane wants at least one row, so a column with more - // panes than the window has rows would walk `y` off the bottom - // and hand renderPane a rect outside the surface (assert, then - // panic — shrink a window with a few stacked panes). Clamp to - // what is left: the panes past the edge get h = 0 and render - // nothing until the window grows back. - const room = p.screen_h -| y; - const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); - p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; - y +|= ch; - } - } - } - pub fn theme(p: *const Pardes) *const Theme { - if (p.custom_theme_active) return &p.custom_theme.?; + if (p.custom_theme) |*custom| return custom; return &themes[p.settings.theme]; } @@ -15971,15 +11287,9 @@ pub const Pardes = struct { if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; - for (p.panel_tracks) |track| if (track != null and track.?.active()) return true; - for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| - if (track.active()) return true; - return false; + return p.presentation.animating(); } - /// Arm the pulse. Look wins a tie because a Look that runs a command - /// (`` @`ls` ``, which is one gesture spelled as both) is felt as the - /// thing the user asked for, not as the shell it happened to need. fn noteHaptic(p: *Pardes, pulse: Haptic) void { if (comptime platform != .macos) return; if (p.haptic == .look) return; @@ -16007,13 +11317,9 @@ pub const Pardes = struct { }; } - /// The sole live-session theme mutation path. Target colors change now; - /// anchored chrome retargets from its currently displayed palette. Only - /// PDFs whose pixels depend on target theme colors are marked stale, once; - /// untinted source rasters remain byte-for-byte resident. pub fn setThemeIndex(p: *Pardes, index: usize) void { if (index >= themes.len or - (!p.custom_theme_active and p.theme_file_path.get().len == 0 and + (p.custom_theme == null and p.theme_file_path.get().len == 0 and index == @as(usize, p.settings.theme))) return; const target_chrome = ChromeTheme.fromTheme(&themes[index]); if (p.animate_theme_changes) @@ -16022,7 +11328,6 @@ pub const Pardes = struct { p.chrome_animation.snap(target_chrome); if (p.custom_theme) |theme_value| { p.custom_theme = null; - p.custom_theme_active = false; std.zon.parse.free(p.gpa, theme_value); } if (p.theme_file_path.get().len > 0) { @@ -16037,12 +11342,13 @@ pub const Pardes = struct { // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - file_pane.refreshHighlights(p); + panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { + const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); - s.cells = try p.gpa.alloc(Cell, ncells); + s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; @@ -16060,43 +11366,10 @@ pub const Pardes = struct { const pane = slot.* orelse continue; try p.renderPane(arena, pane, p.rects[id], id, id == p.active); } - // ---- the transient message row: the pane's last body row ---- - // - // An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane - // with neither a message nor an armed prompt is not touched at all. - // Drawn after every pane's body so it lands OVER whatever that row was - // showing, and painted across the whole row — it is the tagline's twin, - // and reading as one strip rather than a stamp on a body line is what - // keeps it from being mistaken for content. Out here rather than at the - // end of renderPane because renderPane returns early for a native PDF - // page and for an image, and a `/` on a PDF is a real search whose - // prompt has to be visible like any other. - // - // Exactly two things can occupy the row and an ARMED PROMPT beats a - // MESSAGE, because they are not the same kind of thing: a message is a - // report of what already happened and the next keystroke wipes it, a - // prompt is what that keystroke is being typed into and it lives until - // Enter or Esc. - // - // ponytail: the row is draw-only. A click on it lands wherever the body - // under it says (tag clicks map to tag_col on the TAG row), so a prompt - // that moved off the tagline cannot be clicked into or swept the way it - // could up there — the keyboard still edits it in full. Upgrade path - // is a hit test here that maps a press on this row to tag_col + the - // marker offset, i.e. the tag row's own mapping with a constant added. for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; const r = p.rects[id]; - // no body row (a one-row pane, or one squeezed out entirely): the - // tag row is not ours to overwrite, so the message just waits. One - // guard for both placements, because the row picked below is the - // last BODY row either way — "the pane has a body row" is the whole - // condition, and it is what keeps r.h - 1 - BOX_H from underflowing - // or landing above the pane. if (r.w <= config.GUTTER or r.h <= BOX_H) continue; - // the same one input model renderPane cut off the tagline; only one - // of the two can ever be armed (a body key arms one, exitTagEdit - // clears both) const prompt_at = pane.promptAt(); const text = if (prompt_at) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..] @@ -16108,34 +11381,13 @@ pub const Pardes = struct { if (text.len == 0 and !leader_here) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; - // the pane's last BODY row: its last row outright, or one up from - // that when Tagbottom has taken the last for the tagline. The first - // cut of Tagbottom sent this row to the pane's FIRST instead — the - // far end, symmetric with the tag — which put the prompt you are - // typing as far as the pane allows from the tag you are typing - // into. Beside the tagline is where it is read, so it stays there. const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; - // In the EDITOR's colours, not the tag bar's: this row is the one - // place the program talks back to you about the buffer you are in, - // and it reads as part of that buffer rather than as another strip - // of chrome. th and not chrome for the same reason a selection - // uses th — it is not attached to any geometry, so it arrives with - // the theme instead of sliding in over the chrome animation. const msg_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; - // the WHOLE row, the way the tagline fills its own before printing: - // a message is a section and not a stamp, and print writes only the - // cells it needs — so without the fill the body row shows through - // to the right of a short message and reads as one garbled line. s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style); - // The pending SPC leader path, right-aligned. It used to sit on the - // active pane's tagline, where it had to fight the builtins in the - // tail for the same columns; down here it is beside the rest of the - // transient state, and printed AFTER the message so a long one - // loses its last columns rather than hiding what you are typing. if (leader_here) { var ibuf: [16]u8 = @splat(' '); @memcpy(ibuf[1..4], "SPC"); @@ -16147,17 +11399,12 @@ pub const Pardes = struct { const w: u16 = @intCast(iw); if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); } - // ...and the cursor follows the text it edits. tag_col is a byte - // offset, so the prompt maps its suffix through display widths; a - // cursor LEFT of the marker is still over the part - // of the tag that stayed on the tagline, and the tag cursor - // renderPane already placed there is the right one. if (id != p.active) continue; const at = prompt_at orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col >= prompt0) { - const prompt_col = file_pane.displayWidth(text[0..@min(col - prompt0, text.len)]); + const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } @@ -16166,9 +11413,6 @@ pub const Pardes = struct { // global tagbar: full width, top row s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, - // The blank tail is the same visible band as the printed words. - // SDL used to repair this at draw time by special-casing row zero, - // but native hosts consume the role carried by each cell. .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; @@ -16177,16 +11421,11 @@ pub const Pardes = struct { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); - // The topbar is executable chrome, just like a button row. Pane text - // already previews the exact operand a Look would use; row zero has - // no Pane and used to fall through that machinery without any pointer - // feedback at all. Paint the same word the click dispatcher resolves, - // immediately, while leaving whitespace inert. if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); - if (wordBoundsAtCol(bar, file_pane.rawAtDisplay(bar, p.hover_col))) |bounds| { - var col = file_pane.rawDisplayCol(bar, bounds.lo); - const hi = file_pane.rawDisplayCol(bar, bounds.hi); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo); + const hi = panes.File.rawDisplayCol(bar, bounds.hi); while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; @@ -16195,32 +11434,17 @@ pub const Pardes = struct { } } } - // the topbar's cursor, if it has the keyboard. AFTER the pane loop on - // purpose: there is exactly one Surface cursor and the bar's must beat - // the active pane's. Always a block — the bar has no insert mode. if (p.topbar_col) |c| { - const col = file_pane.rawDisplayCol(p.topbar(&tb_buf), c); + const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c); if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false }; } - // resize-handle hint / drag previews: a dash overlay that keeps the - // underlying colors (border drags + hover), or the move indicator. A - // drag holds the coordinates of the last mouse event, so a resize - // mid-drag (tiling WM, font-size change) can leave them off the new - // surface — every arm below checks before it draws. switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } - // a corner lights BOTH splits, which is the whole tell that you - // grabbed the crossing and not an edge. The horizontal half runs - // across ITS OWN column and stops at the vertical preview rather - // than at that column's current edge, so the two dashes stay - // joined at the cell under the mouse: through the handle for a - // left-column corner, butting into it from the right neighbour - // for a right-column one. if (d.corner) |k| if (d.cur_y < s.rows) { var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; @@ -16232,7 +11456,7 @@ pub const Pardes = struct { while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, .move => |d| if (d.cur_x < s.cols) { - if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); @@ -16257,12 +11481,6 @@ pub const Pardes = struct { while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } - // the containment test above can only ever light the column the - // hovered cell is IN, and a v handle is its LEFT column's cell. - // So when that column has no seam here but its right neighbour - // does, light the neighbour's: that is the corner a press would - // take (Drag.border_v), and a grabbable crossing has to be - // visible before it is grabbed. for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (p.seamIdxAt(cn, p.hover_row) != null) continue; @@ -16283,19 +11501,15 @@ pub const Pardes = struct { var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; - sb_total = file_pane.nlines(p.gpa, f); + sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { - const sb = term_pane.scrollbar(at); + const sb = panes.Terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } - // The face belongs to the SHELL, so the core can only name the one - // it was asked for; nothing has asked when this is empty and the - // shell is still in whatever it booted in. Clamped because a name - // is a file stem and a path component can be as long as a path. const effective_font = p.settings.font.effective_name.get(); const font_name = if (effective_font.len == 0) "default" else effective_font; var ov_buf: [256]u8 = undefined; @@ -16334,60 +11548,17 @@ pub const Pardes = struct { } } }; - p.submitted_panel_layout = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - p.submitted_panel_layout[id] = .{ - .serial = pane.serial, - .box = panelBox(p.rects[id]), - }; - } - p.submitted_panel_layout_ready = true; - // The old grid stays frozen for the transition, while terminals, - // watches, hover chrome, and other live data may change the new grid - // between samples. Rebuild the cheap byte mask every frame so the - // published (old, new, changed) triple is always coherent. - if ((p.panel_diff_pending or p.panel_diff_ready) and !try p.preparePanelDiff()) { - // There was no successfully presented same-sized old grid. A - // content effect cannot guess one: snap this transition rather - // than animating uninitialised/stale cells. - for (&p.panel_tracks) |*slot| { - const track = slot.* orelse continue; - if (track.effect.needsPreviousGrid()) slot.* = null; - } - p.nclosing_panel_tracks = 0; - p.panel_diff_pending = false; - p.panel_diff_ready = false; - } - if (p.panel_diff_ready) { - s.previous_cells = p.presented_cells; - s.cell_diffs = p.panel_cell_diffs; - } - // Moving panes first, then new panes, then inert closing tombstones on - // top. The rule is `panel_animation.paintOrder` so that it has exactly - // one definition: every host receives this same deterministic dense - // record set and none of them needs to sort it again. - s.npanel_tracks = panel_animation.paintOrder( - &p.panel_tracks, - p.closing_panel_tracks[0..p.nclosing_panel_tracks], - &s.panel_tracks, - ); + try p.presentation.submit(p, s); return p.composeAsciiTransitions(arena, s); } - // EFFECT_CODE_ASCII_COMPOSITOR_BEGIN - /// Lazily copy the canonical grid only when an active core-composed - /// character track still shows something other than its final glyph. The - /// returned Surface is the sole backend boundary, so every shell rasterizes - /// the exact same intermediate characters and style-only/non-ASCII changes - /// pass through once. fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { if (!track.effect.composedByCore() or track.phase == .closing) continue; - const area = panel_animation.CellArea.of(track.to); + const area = layout.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; @@ -16411,15 +11582,10 @@ pub const Pardes = struct { return presented orelse canonical; } - /// The character one cell presents this frame, or null when the canonical - /// cell is already the honest answer. PanelAscii walks the semantic byte - /// distance of a *changed* cell; the motion effects carry every glyph in - /// the pane, because text flying in from a screen edge has to bring its - /// unchanged glyphs along with it. fn composedCell( canonical: *const Surface, - track: panel_animation.Track, - area: panel_animation.CellArea, + track: layout.Track, + area: layout.CellArea, col: u16, row: u16, index: usize, @@ -16431,13 +11597,10 @@ pub const Pardes = struct { }; const byte = diff.byteAt(track.frame); if (byte == diff.to) return null; - // Frame zero is the exact old cell. Once a byte is walking, the - // semantic destination owns presentation style, and at the endpoint - // the untouched canonical cell wins bit-for-bit instead. if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } - return switch (panel_animation.charSource(track, col - area.x0, row - area.y0, area)) { + return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it @@ -16480,50 +11643,6 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } - fn preparePanelDiff(p: *Pardes) !bool { - const count = p.surface.cells.len; - if (!p.presented_cells_valid or - p.presented_cells_cols != p.surface.cols or - p.presented_cells_rows != p.surface.rows or - p.presented_cells.len != count) return false; - if (p.panel_cell_diffs.len != count) { - const next = try p.gpa.alloc(PanelCellDiff, count); - if (p.panel_cell_diffs.len > 0) p.gpa.free(p.panel_cell_diffs); - p.panel_cell_diffs = next; - } - for (p.panel_cell_diffs, p.presented_cells, p.surface.cells) |*diff, *old, *new| - diff.* = PanelCellDiff.between(old, new); - // The fixed Track ABI already carried two padding bytes after frame. - // They now hold the core-computed ASCII duration: exactly one sample - // beyond the longest eased walk in this pane, so there is neither a - // forced endpoint jump nor a long invisible tail for nearby glyphs. - for (&p.panel_tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - if (track.effect != .ascii) continue; - var longest: u16 = 1; - var row: u16 = 0; - while (row < p.surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < p.surface.cols) : (col += 1) { - if (!boxContainsCell(track.to, col, row)) continue; - const index = @as(usize, row) * p.surface.cols + col; - switch (p.panel_cell_diffs[index]) { - .ascii => |diff| longest = @max(longest, diff.frameCount()), - .unchanged, .visual => {}, - } - } - } - track.frame_count = @max(track.frame_count, longest); - } - p.panel_diff_pending = false; - p.panel_diff_ready = true; - return true; - } - // EFFECT_CODE_ASCII_COMPOSITOR_END - - /// Paint a selection expressed in the coordinate space used by pointer - /// gestures. Both the persistent mouse selections and the delayed Look - /// preview come through this one clipping/mapping path. fn paintPointerSelection( s: *Surface, pane: *const Pane, @@ -16532,7 +11651,7 @@ pub const Pardes = struct { tw: u16, tag_y: u16, body_y: u16, - sl: Sel, + sl: Pane.Sel, bg: [3]u8, fg: ?[3]u8, ) void { @@ -16543,13 +11662,10 @@ pub const Pardes = struct { var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; - // A Sel row is independent of Tagbottom: zero is the tag and - // BOX_H upward is the body. This is the render-side inverse of - // pointerTextSelection. const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; - // File line numbers occupy PREFIX_W only in the body. The tag is + // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. - var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, config.PREFIX_W)) else c0; + var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else c0; while (col <= c1 and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; @@ -16568,21 +11684,9 @@ pub const Pardes = struct { const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; - // The pane's two anchor rows, computed once: the tagline's, and the - // body's first. The Tagbottom builtin swaps which end each is at and - // NOTHING else in here reads r.y — that is the whole of the feature on - // the render side. r.h == 0 returned above, so the bottom row exists. const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; - // the pane's own background, for everything that has to read as "no - // chrome here": the body text, and the blank right half of the - // scrollbar's second column. `.default` means the host terminal's own - // background, which is what a themeless dark pane wants. const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; - // ...and the same background as a colour to do arithmetic on, which the - // dimmed selections below need. A theme with a null bg cannot say what - // the host's own cell looks like, so its tag bar stands in — the - // substitution pdf_pane.tintColors already makes. const page_rgb = th.bg orelse th.tag_bg; // text area resets to terminal-default cells (vaxis clear semantics); @@ -16595,31 +11699,6 @@ pub const Pardes = struct { if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } - // the layout box: the pane's MODE, one character, in the gutter cells - // of the tag row. Same box you drag a pane by — the whole GUTTER is - // still painted and the `.move` press hit-test in handleMouse is - // untouched — it just carries the one piece of state that used to cost - // four columns of every tagline. - // The glyph goes in column 0, directly above the scrollbar's ink - // column below it, so a pane's chrome reads as one line down its left - // edge; column 1 stays plain colour, and that blank half is what keeps - // the thing reading as a BOX rather than as a letter someone dropped - // in the gutter. - // - // The mode shown is the BODY's. A tag edit hijacks pane.mode to insert - // (tags are always insert; the real one is parked in tag_mode), and a - // badge that flipped every time you clicked a tagline would be - // reporting the tag's mode on the pane's box. Reading tag_mode also - // makes the parked value VISIBLE: a terminal being tag-edited still - // shows `$`, which is exactly the invariant ttyclick.snap exists for. - // - // The ink is picked off the box's own brightness instead of being - // named in the theme, because box/box_dim come from a generated - // theme's cursor and selection colours — light on a light theme — and - // a fixed white would vanish there. Rec.601-ish integer weights. - // ponytail: two colours, black or white, chosen at a fixed threshold. - // Upgrade to the theme's own fg/bg pair when a theme turns up whose - // box wants a tint rather than a contrast. const box_bg = if (active) chrome.box else chrome.box_dim; const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; @@ -16647,44 +11726,23 @@ pub const Pardes = struct { .font_role = .tagline, }); const tag = try p.tagText(arena, pane); - // An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split, - // `|` — is still TYPED INTO the tag tail: same buffer, same offsets, - // same one-line modal editor, same Enter and same Esc. Only where it is - // DRAWN moved. It is cut off the tagline here and printed on the pane's - // message row instead (see render), so the builtins in the tail stay - // readable while you type instead of being pushed off the right edge by - // a long pattern. - // - // The prompt offset is in the tail while tag_col is in the rendered - // tag, so add the live prefix length when clipping the editable span. const prompt_at = pane.promptAt(); const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); - // Paint tag hover before every real tag selection. This common point - // is above the native-PDF/image early returns, so their tag operands - // are no longer hidden, while an explicit sweep remains authoritative. if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } - // tag char selection highlight (helix v/x, or a tagline sweep), - // inclusive [lo, hi], mapped from byte offsets to display cells. - // - // Every selection on screen paints in the LIVE theme (`th`) and not in - // `chrome`: a highlight is not attached to any geometry, it appears - // under the range you just swept, so it has to arrive with the theme - // the way syntax colours do rather than slide in over the chrome - // animation's frames. if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); - var col = file_pane.rawDisplayCol(tag, b.lo); + var col = panes.File.rawDisplayCol(tag, b.lo); const hi = modal.nextGrapheme(tag, b.hi); - const end = file_pane.rawDisplayCol(tag, hi) -| 1; + const end = panes.File.rawDisplayCol(tag, hi) -| 1; while (col <= end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; @@ -16695,21 +11753,15 @@ pub const Pardes = struct { // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) - const col = file_pane.rawDisplayCol(tag, pane.tag_col); + const col = panes.File.rawDisplayCol(tag, pane.tag_col); if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } - // A native PDF page uses the same backend-neutral pixel attachment as - // an image. Without native pixels it deliberately falls through: its - // extracted text projection becomes an ordinary readable body. if (comptime pdf_enabled) - if (hasPdf(pane) and pdf_pane.draw(p, pane, r, id, tx, tw)) return; + if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; - // image pane: the picture fills the body — petscii glyph art into the - // cells, or a pixel attachment the shell places (kitty). Plain - // thumbless gutter so it reads like any other pane. if (pane.image) |*iv| { - image_pane.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); @@ -16739,55 +11791,21 @@ pub const Pardes = struct { } } - // Coloring is one algorithm per pane, chosen by title (colorAlgo): the - // terminal projects its own ANSI, a file lays tree-sitter or diff - // shading over its content. source and diff share this pass because - // both feed f.highlights, which refreshHighlights filled with whichever - // this same choice named. Order is load-bearing — gutter, recolor, then - // wrap markers; the selection/cursor passes below win over all three. const tz_color = tracy.zone(@src(), "paneRecolor"); switch (pane.colorAlgo()) { - // Every mode, not just `.tty`: `recolorAnsi` translates a row's - // colour anchor through the same slide the edit buffer applied to - // its text, so leaving a shell for normal mode no longer drains - // the screen of colour. A row the user typed has no ANSI and is - // skipped there, which is why this needs no mode test. - // `body` is the very text printed above: `recolorAnsi` pairs its - // graphemes with the cells that spelled them, which is the only way - // to stay on the right glyph when the emulator and this surface - // disagree about how many columns a cluster is worth. - .tty => if (p.settings.colors) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, body), + .tty => if (p.settings.colors) panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body), // `.locations` joins them because it feeds the same `f.highlights` // — only the pass that FILLED it differs (refreshHighlights). .source, .diff, .locations => { const f = &pane.file.?; - file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); - if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); - file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.settings.colors) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); + panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); }, .none => {}, } tz_color.end(); - // mouse selections (pane-local coords), one pass per button — later - // buttons win on overlap. A left .done stays highlighted after release; - // middle/right .done are transient (they fire their action on release). - // - // Which button drew a sweep is worth seeing, so each gets the theme's - // selection tinted toward one of the theme's own syntax accents: three - // colours that are visibly not each other on a dark theme and on a - // light one, without asking a theme to name three more. A QUARTER of - // the accent, which is the mix of a mix — at a half, an accent as - // bright as the theme's text lands on top of sel_fg and the selected - // text stops being readable on a couple of dozen generated themes. - // `num` gives way to `comment` when a theme paints numbers and strings - // alike — the shipped helix theme does, 24 of the generated ones do — - // because two buttons landing on one colour is the whole thing this - // avoids. - // ponytail: 20 of the 228 themes colour two of these three scopes the - // same anyway and still collapse two buttons. Upgrade path is to walk - // the theme for a third colour far enough from the other two, rather - // than naming the scopes here. const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ mix(th.sel_bg, mix(th.sel_bg, th.kw)), @@ -16800,7 +11818,7 @@ pub const Pardes = struct { // win over this quiet affordance. const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| - file_pane.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) + panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| if (@max(sel.r0, sel.r1) >= BOX_H) paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); @@ -16826,12 +11844,9 @@ pub const Pardes = struct { const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { - // walked by SCREEN row and asked what LINE each one shows, - // because a wrapped line is several rows. wrapAt degenerates to - // `off + row` when nothing wrapped, which is what this was. const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; if (ar < lo or ar > hi) continue; - var col: u16 = if (pane.file != null) config.PREFIX_W else 0; + var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - BOX_H); cell.default = false; @@ -16840,29 +11855,17 @@ pub const Pardes = struct { } } } - // modal char selection (helix `v`): stream-shaped anchor..head - // highlight. The EXTRA cursors are the same shape drawn dimmer, and - // each of them also paints its own cursor cell bright: there is one - // hardware cursor and the primary owns it, so a secondary cursor has - // to be a cell colour or it is invisible. - // The extra cursors show in INSERT mode too — that is exactly when you - // need to see where your typing is landing — while the primary's - // selection highlight stays normal-mode-only, as it always was. - // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even - // though the pane is in insert mode for the tag and even when the match - // is a single cell: the hardware cursor is off in the tag, so a preview - // that leans on it shows every match except the one you are on. const preview = selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or (modal_hover and pane.vsel.active); const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { - const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; + const vpfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var si: usize = 0; while (si <= pane.nsel) : (si += 1) { const primary = si == pane.nsel; if (if (primary) !show_prim else !show_extra) continue; - const sr = if (primary) SelRange{ + const sr = if (primary) Pane.SelRange{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, @@ -16870,27 +11873,12 @@ pub const Pardes = struct { } else pane.sels[si]; const bnd = cellBounds(sr); const hover_only = primary and modal_hover and pane.mode != .normal and !preview; - // an extra cursor is the selection colour turned down: the - // highlight pulled halfway back to the page, so the primary is - // the one that reads as "here" at a glance. On a light theme - // that dims toward white rather than toward black, which is the - // same statement. The INK stays put — helix's two selection - // styles differ in their background too, and dimming both ends - // walks the text and its cell toward each other until neither - // is readable on the themes whose selection is already close to - // the page. const bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else mix(th.sel_bg, page_rgb); - // ...and this walks SCREEN rows too, asking the map which line - // and which byte column of it each one shows. Unwrapped that is - // `off + vr` / `hscroll`, i.e. the arithmetic this was, and it - // is also the cheaper loop: a linewise selection over a whole - // file used to iterate once per LINE to reject all but a - // screenful of them. var vr: i32 = 0; while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { const w = pane.wrapAt(vr); @@ -16898,16 +11886,16 @@ pub const Pardes = struct { const visible_line = modal.lineSlice(body, @intCast(vr)); const cstart: i32 = if (w.line == bnd.lo_row) (if (pane.file != null) - file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) + panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col) else - file_pane.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx + panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; const cend: i32 = if (w.line == bnd.hi_row) (if (pane.file != null) - file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) + panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else - file_pane.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx + panes.File.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); @@ -16923,9 +11911,9 @@ pub const Pardes = struct { const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, BOX_H); const ccol = (if (pane.file != null) - file_pane.displayOffset(pane, sr.row, cw.at, sr.col) + panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), @@ -16942,22 +11930,19 @@ pub const Pardes = struct { // cursor: tracks the shell cursor until pinned by a click or a key // (the tag cursor above wins while the tag is focused) if (active and !pane.tag_edit) { - const cur = term_pane.gridCursor(pane); if (pane.mode != .tty) { - const goff = term_pane.gridOffset(pane); + const cur = panes.Terminal.gridCursor(pane); + const goff = panes.Terminal.gridOffset(pane); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); - // which ROW of a wrapped line the cursor is on, and which byte - // column that row starts at — `off`/`hscroll` when nothing - // wrapped, so this is the same two lines it always was const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, BOX_H); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) - @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, crow, cwp.at, ccol) else if (pane.cur_pinned) - file_pane.lineDisplayOffset( + panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), @@ -16966,35 +11951,17 @@ pub const Pardes = struct { ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert }; - } else if (cur.y + BOX_H < r.h and cur.x < tw) { - s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y, .bar = pane.mode == .insert }; + } else if (panes.Terminal.visibleCursor(pane)) |cur| { + if (cur.y + BOX_H < r.h and cur.x < tw) + s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } - // gutter below the tag row: scrollbar track + thumb. (The move box on - // the tag row itself is drawn up with the tag, since it now carries - // the mode and belongs with the rest of that row.) - // - // The bar is ONE column: column 1 is the track/thumb, column 2 is the - // pane's own background. That second fill is not optional — render() - // pre-fills the whole surface with scroll_track so the gaps between - // panes read as chrome, so a column left unpainted here keeps the - // track colour and the bar looks two wide again. - // - // The move box above stays the full GUTTER even though only half of it - // has ink in it. It is a target you drag, not a gauge you read, and its - // whole width is the affordance — the step where the box ends and the - // narrower bar begins is the one place on screen that says those are - // two different pieces of chrome. - // - // Nothing here touches layout or hit-testing: the gutter is still - // config.GUTTER columns and the click handlers still scroll on any of - // them, so the blank column is still live. Only the ink narrowed. if (r.h > BOX_H) { s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ - .total = file_pane.nlines(p.gpa, f), + .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ @@ -17002,7 +11969,7 @@ pub const Pardes = struct { .offset = page, .len = 1, } else blk: { - const gsb = term_pane.scrollbar(pane); + const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = r.h - BOX_H; @@ -17033,20 +12000,14 @@ pub const Pardes = struct { return true; } - /// The pane body as text. Image: blank rows (the picture draws over it). - /// File: line-numbered content from f.scroll. - /// Terminal: viewport rows, padded to the grid height, prompt rows blanked - /// outside tty mode (OSC 133), the edit buffer standing in for the shell - /// rows it covers — which is where a buffer holding more lines than those - /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } - if (hasPdf(pane)) { - if (comptime pdf_enabled) return pdf_pane.visibleText( + if (pane.hasPdf()) { + if (comptime pdf_enabled) return panes.Pdf.visibleText( &pane.pdf.?, p.pdf_gpa, arena, @@ -17054,17 +12015,14 @@ pub const Pardes = struct { ); unreachable; } - if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap); - return term_pane.bodyText(arena, pane); + if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); + return panes.Terminal.bodyText(arena, pane); } }; test "Esc alternates between two panes of the SAME kind" { if (platform == .web) return; const gpa = std.testing.allocator; - // An ABSOLUTE boot path, the way main.zig resolves argv: a file pane's - // directory is its path's dirname, and a relative one leaves nothing for - // the look below to resolve against. var cwdbuf: [4096]u8 = undefined; const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return))); var pathbuf: [4096]u8 = undefined; @@ -17074,9 +12032,6 @@ test "Esc alternates between two panes of the SAME kind" { defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 40 } }); const a = p.active; - // A second FILE. This is the case the doc<->terminal hop Esc used to run - // could not do AT ALL: both panes are docs, so it had nothing of "the other - // kind" to reach and Esc did nothing. p.runBuiltin(.Look, a, "", "build.zig"); p.sync(); const b = p.active; @@ -17093,10 +12048,6 @@ test "Esc alternates between two panes of the SAME kind" { test "a boot file that will not open boots an errors pane rather than failing the launch" { if (platform == .web) return; const gpa = std.testing.allocator; - // A path `look.resolve` would have accepted and `readFile` cannot open. - // Spelled as a name that is simply not there rather than by chmod-ing a - // fixture to 000, because the second answers differently when the suite - // runs as root and this must fail the same way everywhere. const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, @@ -17107,7 +12058,7 @@ test "a boot file that will not open boots an errors pane rather than failing th const pane = p.panes[0].?; const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // The reason IN WORDS, not an error name: `PermissionDenied` on a screen // is jargon, and the whole point of this pane is that a human reads it. try std.testing.expect(std.mem.indexOf(u8, f.content, "cannot open") != null); @@ -17129,26 +12080,99 @@ test "argv naming nothing boots an errors pane rather than failing the launch" { // shell a bare `pardes` boots: the answer is the whole screen. try std.testing.expectEqual(@as(u8, 1), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); - try std.testing.expectEqual(@as(usize, 0), p.col_terms[0][0]); + try std.testing.expectEqual(@as(usize, 0), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.active); for (p.panes[1..]) |slot| try std.testing.expect(slot == null); const pane = p.panes[0].?; try std.testing.expect(!pane.isTerminal()); const f = pane.file.?; - try std.testing.expectEqual(output_pane.Origin.errors, f.output.?.from); + try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // ...and it says what happened AND what was asked for. The word as typed, // which is the half a bare "not found" leaves out. try std.testing.expect(std.mem.indexOf(u8, f.content, "not found") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "notes/tdoo.md") != null); - // THE PANE'S DIRECTORY IS THE LAUNCH DIRECTORY, asserted through the path - // because that is what `paneDir` reads: it decides where a `Grep` from - // this pane walks, where its `Newtty` spawns, and what its `Save` - // prefills. Passing "" here put the pane at `/+Errors`, i.e. rooted every - // one of those at `/`. try std.testing.expectEqualStrings("/home/pardes-test/work/+Errors", f.path); } +test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { + 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(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + p.update(.{ .output = .{ .pane = 0, .bytes = "abc" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const visible = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; + try std.testing.expectEqual(rect.x + config.GUTTER + 3, visible.x); + try std.testing.expectEqual(body_y, visible.y); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + pane.mode = .normal; + pane.cur_pinned = true; + pane.cur_row = 0; + pane.cur_col = 1; + const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); + try std.testing.expectEqual(body_y, modal_cursor.y); + p.enterTagEdit(pane, 0); + const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); + Pardes.exitTagEdit(pane); + pane.mode = .tty; + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25h\x1b[?1049h\x1b[2;5H" } }); + const alternate = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(rect.x + config.GUTTER + 4, alternate.x); + try std.testing.expectEqual(body_y + 1, alternate.y); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l\x1b[?25h" } }); + const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + try std.testing.expectEqual(visible.x, returned.x); + try std.testing.expectEqual(visible.y, returned.y); +} + +test "raw terminal cursor follows its active row only while that row is visible" { + 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 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + p.settings.tag_bottom = tag_bottom; + const pane = p.panes[0].?; + pane.mode = .tty; + for (0..60) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "row\r\n" } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;4H" } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const live = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; + const rect = p.rects[0]; + const body_y = if (tag_bottom) rect.y else rect.y + BOX_H; + 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); + 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)); + try std.testing.expect((try p.render(frame.allocator())).cursor == null); + 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); + } +} + test "Esc back into a tty leaves its view at the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -17168,7 +12192,7 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); - try std.testing.expectEqual(Mode.tty, sp.mode); + try std.testing.expectEqual(Pane.Mode.tty, sp.mode); // tty mode follows output to the bottom, so a screenful and a half of it // rides the view down and leaves that pin far up in the scrollback. @@ -17177,7 +12201,7 @@ test "Esc back into a tty leaves its view at the prompt" { p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - const live = sp.vt.screens.active.pages.scrollbar().offset; + const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // out to the doc @@ -17186,16 +12210,13 @@ test "Esc back into a tty leaves its view at the prompt" { p.sync(); try std.testing.expectEqual(shell, p.active); - // The prompt is still on screen. Restoring the stale pin used to yank the - // view up to scrollback row 0, where it sat until the next keystroke's echo - // scrolled it back down — "type something and the tty jumps to the prompt". - try std.testing.expectEqual(live, sp.vt.screens.active.pages.scrollbar().offset); + try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/allocators.zig" }); + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/memory.zig" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc @@ -17250,21 +12271,18 @@ test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does. const shift_esc: Key = .{ .cp = Key.escape, .shift = true }; p.update(.{ .key = shift_esc }); - try std.testing.expectEqual(Mode.tty, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); - // ...and out of tty it is Escape-in-normal-mode instead of a toggle: the - // doc takes focus and the shell KEEPS its tty mode, so coming back lands - // in the program you left rather than in normal mode on top of it. p.update(.{ .key = shift_esc }); try std.testing.expect(p.active != shell); try std.testing.expect(!p.panes[p.active].?.isTerminal()); - try std.testing.expectEqual(Mode.tty, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); // The configured Ctrl-key is now the only thing that leaves tty in place. p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); - try std.testing.expectEqual(Mode.normal, shell_pane.mode); + try std.testing.expectEqual(Pane.Mode.normal, shell_pane.mode); try std.testing.expectEqual(shell, p.active); } @@ -17309,9 +12327,6 @@ test "hopping between two panes does not grow the jump stack" { } try std.testing.expectEqual(depth, p.njumps); - // The collapse must not eat history: Back still walks OUT of the ping-pong - // to the place before it, which is what makes the entry a cursor move - // rather than a deletion. const before = p.active; p.runBuiltin(.Back, p.active, "", null); p.sync(); @@ -17336,10 +12351,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const pane = p.panes[0].?; - // An ordinary yank fills the DEFAULT REGISTER and asks the shell for - // nothing. This is the whole helix split, and the bug it closes: before - // it, every y/d/c mirrored out, so deleting one character threw away - // whatever the desktop was holding. _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expect(p.yank != null and p.yank.?.len > 0); @@ -17354,9 +12365,6 @@ test "only the SPC clipboard commands cross to the system clipboard" { const yanked = p.yank orelse return error.MissingYank; try std.testing.expect(drainedEffect(p, .set_clipboard)); - // `SPC p` cannot read the clipboard itself: it ASKS, and the answer comes - // back as an ordinary paste event whenever (or never — a terminal may - // refuse the OSC 52 read, which is a no-op and not a hang). const before = pane.file.?.content.len; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'p' } }); @@ -17412,13 +12420,10 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.enterTty(p, 0); - try std.testing.expectEqual(Mode.tty, pane.mode); + panes.Terminal.enterTty(p, 0); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); p.setYank("one\ntwo"); - // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the - // newline becomes Enter's \r — a raw \n would run `one` and leave `two` - // half-typed. p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); // and it asked the desktop for nothing on the way @@ -17426,13 +12431,13 @@ test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { // Under mode 2004 the same keystroke brackets instead, which is what stops // readline from RUNNING a multi-line paste. - pane.vt.modes.set(.bracketed_paste, true); + pane.terminal.?.vt.modes.set(.bracketed_paste, true); p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the // answer lands at the program rather than in an edit buffer. - pane.vt.modes.set(.bracketed_paste, false); + pane.terminal.?.vt.modes.set(.bracketed_paste, false); p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); try std.testing.expect(p.clip_pending != null); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); @@ -17452,7 +12457,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - term_pane.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" }); @@ -17470,12 +12475,8 @@ 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); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); - // Bigger than `effect_cap * 64` (256 KiB), which is where the ring stops - // taking chunks: the tail used to be refused and the program saw 256 KiB of - // a 300 KiB paste with nothing said. Position-dependent bytes, so a - // reordered or duplicated chunk fails as loudly as a missing one. const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26)); @@ -17487,7 +12488,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 !term_pane.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(); @@ -17495,14 +12496,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); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); - // The program asks for brackets, so `typeToTty` emits marker, text, marker. - // The CLOSING one is queued last and was therefore the first casualty of a - // full ring: the program stayed in paste mode and read every later - // keystroke as pasted text. Worse than losing the bytes. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); - try std.testing.expect(term_pane.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'); @@ -17522,15 +12519,12 @@ 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); - term_pane.enterTty(p, 0); + panes.Terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'q'); p.update(.{ .paste = text }); - // Parked and deliberately NOT drained — a session killed mid-paste. The - // copy is the core's, so `std.testing.allocator` fails this test through - // the `deinit` above if shutdown forgets it. try std.testing.expect(p.pending_write_bytes > 0); } @@ -17555,14 +12549,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 = term_pane.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 = term_pane.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; @@ -17577,18 +12571,12 @@ test "leaving tty hides the prompt and keeps the command typed at it" { bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); - // Out of tty the prompt goes and the command stays — LEFT-HUGGED, so it - // lines up with the output below instead of sitting in a bay of blanks - // where the prompt used to be. sp.mode = .normal; try std.testing.expectEqualStrings( "grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); - // The MOTION SURFACE cuts either way, and deliberately: it is what the - // cursor moves over, and in tty mode nothing moves over it — the keys all - // belong to the program. p.shell_rows.stale = true; try std.testing.expectEqualStrings( "grep -rn TODO src/", @@ -17624,7 +12612,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 term_pane.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; @@ -17638,7 +12626,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" sp.cur_col = 3; sp.cur_pinned = true; while (p.nextEffect()) |_| {} - term_pane.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'. @@ -17655,24 +12643,6 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" try std.testing.expectEqual(@as(usize, 7), lefts); } -// THE BOARD'S MEMORY PRESSURE, REPRODUCED ON AN ORDINARY NATIVE TARGET. -// -// These live in pardes.zig and not in limits.zig because every one of them -// drives `Pardes.init`: the table alone cannot say what a boot costs. The one -// test that needs nothing but the numbers — the desktop-capacity regression -// guard — stays in src/limits.zig. -// -// All three use the FixedBufferAllocator (or the accounting FailingAllocator) -// as the CORE'S OWN gpa and hand the same allocator to every `Options` arena, -// mirroring src/esp32p4.zig's `allocators.init(a)`: on the board every tier is a -// `StackFallbackAllocator` with a 4 KiB or zero buffer, so effectively all of -// it spills onto the single heap. Calling `allocators.init` here instead would -// hand a desktop build its 32 MiB static `.bss` tier and serve every request -// out of that, making a 384 KiB budget mean nothing. - -/// The board grid. These mirror `pardes_config.esp32p4_cols/esp32p4_rows` (build.zig -/// defaults, read at src/esp32p4.zig:235) rather than reading them, because a -/// native build does not set the P4 options at all. const board_cols: u16 = 56; const board_rows: u16 = 14; @@ -17680,9 +12650,6 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { return .{ .cols = cols, .rows = rows, - // No pty is spawned by a test host, but `tty_only` is the one-pane boot - // and therefore the closest a hosted build gets to the board's - // single-output-buffer boot. .tty_only = true, .frame_allocator = gpa, .image_allocator = gpa, @@ -17691,52 +12658,18 @@ fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { }; } -/// A boot and its teardown as one `!void` call. `checkAllAllocationFailures` -/// requires exactly that shape and `Pardes.init` returns `*Pardes` with a -/// `deinit` obligation, so the harness cannot call it directly. fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); p.deinit(); } -// WHAT THE BOARD'S 384 KiB BUYS, CHECKED FROM A DESKTOP. -// -// What a hosted build CANNOT do is boot in 384 KiB, and the reason is not a -// capacity: `@sizeOf(Pane)` carries the ghostty-vt Terminal, 1.1 MiB of it, and -// what removes that on the board is `terminal_panes` — a CAPABILITY keyed on -// the platform (src/limits.zig says why it is not in the table). So there is no -// build option that turns a desktop into the board, and a test that pretended -// otherwise would be asserting a FixedBufferAllocator refuses a 2.2 MiB -// request. That was written, it asserted nothing, and it is gone. -// -// What a hosted build CAN check is every product the board's budget is spent -// on, because both factors are visible here: the board's CAPS are literals in -// src/limits.zig, and the ELEMENT SIZES are the same structs this target -// compiles (`Effect`, `Snapshot`, `Cell` and `PanelCellDiff` hold no pointers, -// so riscv32 and x86_64 agree about all four). That is the product a code -// change actually moves: nobody shrinks the board's heap, but somebody adds a -// `Buf(512)` arm to `Effect` and costs it 49 KiB it does not have. -// -// The caps are spelled as LITERALS rather than read from `limits`, because on -// this build `limits` holds the desktop numbers; these are the board's, they -// are its contract, and a derivation would agree with itself. -// -// MEASURED on x86_64-linux Debug at this commit: `@sizeOf(Effect)` 272, -// `@sizeOf(term_pane.Snapshot)` 56, `@sizeOf(Cell)` 26, `@sizeOf(PanelCellDiff)` 3 — -// so the three products are 34,816 + 1,792 + 43,120 = 79,728 bytes, a fifth of -// the heap, against bounds of 49,152 / 12,288 / 49,152 and a total of 196,608. test "board heap: every inline ring the board pays for still fits its budget" { const heap = limits.board_heap_bytes; - // THE EFFECT RING, the largest single inline cost in `Pardes` — 4096 - // entries on a desktop is 1.09 MiB of the 1.14 MiB the struct occupies. - // The board holds 128 (src/limits.zig `effect_cap`). 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(term_pane.Snapshot); - // ...and the three per-cell arrays the core owns at the board's own grid, - // which the next test pins the SHAPE of; this one pins the COST. + 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 @@ -17744,45 +12677,12 @@ test "board heap: every inline ring the board pays for still fits its budget" { try std.testing.expect(effect_ring <= heap / 8); try std.testing.expect(undo_history <= heap / 32); try std.testing.expect(grid <= heap / 8); - // ...and together, against the half of the heap the firmware measured as - // available after its own .bss, stack and vaxis's two grids. Three eighths - // is what the individual bounds already allow; asserting the sum as well is - // what catches two of them growing a little each. try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); } -// EVERY CELL IS PAID FOR FOUR TIMES on the board — vaxis's `Screen` and -// `InternalScreen`, and the core's `Surface.cells` and `presented_cells` — plus -// the core's per-cell diff classification. Two of those four are vaxis's and -// invisible from here; what this pins is the three buffers the CORE owns, so -// that adding a fourth core-owned per-cell array fails loudly instead of -// quietly costing the board another 20 KiB. -// -// MEASURED at this commit: `@sizeOf(Cell)` is 26 and `@sizeOf(PanelCellDiff)` -// is 3, so the core spends 55 bytes per cell — 43,120 bytes at the board's -// 56x14 grid, an eighth of the whole heap and the largest single grid-scaled -// cost in the program. -test "board heap: the core owns exactly three per-cell arrays" { +test "board heap: transition grids fit the budget and the surface borrows its snapshots" { const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); - // Five NAMES, three BUFFERS: `Surface.previous_cells` and - // `Surface.cell_diffs` are views published onto the two the core owns (see - // `render`), so they cost nothing. Counted by reflection because a fifth - // name is exactly the change this test exists to catch. - const grid_slices = comptime blk: { - var n: usize = 0; - for (@typeInfo(Pardes).@"struct".fields ++ @typeInfo(Surface).@"struct".fields) |f| { - const info = @typeInfo(f.type); - if (info != .pointer or info.pointer.size != .slice) continue; - if (info.pointer.child == Cell or info.pointer.child == PanelCellDiff) n += 1; - } - break :blk n; - }; - try std.testing.expectEqual(@as(usize, 5), grid_slices); - - // The diff array is only allocated for a transition that needs the previous - // grid, so the sequence here is the shortest one that makes all three real: - // boot, acknowledge, split with `vertical` armed, render. const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -17791,34 +12691,53 @@ test "board heap: the core owns exactly three per-cell arrays" { p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); - try std.testing.expect(p.layoutSplitColumn(0, 1, false)); + try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); const cells = @as(usize, p.screen_w) * p.screen_h; try std.testing.expectEqual(cells, p.surface.cells.len); - try std.testing.expectEqual(cells, p.presented_cells.len); - try std.testing.expectEqual(cells, p.panel_cell_diffs.len); + try std.testing.expectEqual(cells, p.presentation.previous_cells.len); + try std.testing.expectEqual(cells, p.presentation.diffs.len); + try std.testing.expect(p.surface.previous_cells.ptr == p.presentation.previous_cells.ptr); + try std.testing.expect(p.surface.cell_diffs.ptr == p.presentation.diffs.ptr); + try std.testing.expect(p.surface.cells.ptr != p.surface.previous_cells.ptr); const owned = p.surface.cells.len * @sizeOf(Cell) + - p.presented_cells.len * @sizeOf(Cell) + - p.panel_cell_diffs.len * @sizeOf(PanelCellDiff); + p.presentation.previous_cells.len * @sizeOf(Cell) + + p.presentation.diffs.len * @sizeOf(PanelCellDiff); try std.testing.expectEqual(cells * per_cell, owned); - // ...and the board's own grid has to leave the other seven eighths of the - // heap for everything else. 43,120 of 49,152 at the numbers above; a cell - // that grew by two bytes would spend the margin. try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= limits.board_heap_bytes / 8); } -// EVERY ALLOCATION IN A BOOT, FAILED IN TURN. Eight of them at this commit, so -// the sweep is eight boots and costs milliseconds. `checkAllAllocationFailures` -// is the whole test because it asserts precisely the three things that matter: -// a failed allocation surfaces `error.OutOfMemory` rather than being swallowed -// into a half-built instance, `allocated_bytes == freed_bytes` at that point -// (so the failure path needs no `deinit` and leaves nothing dangling), and the -// allocation count is deterministic. +test "LSP failure clears only its matching wait without completion indentation" { + if (!lsp.supports.contains(.completion)) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("x"); + pane.mode = .insert; + pane.cur_col = 1; + + p.lspRequest(p.active, .completion, ""); + const old_id = p.lsp_wait.?.id; + p.lspRequest(p.active, .completion, ""); + const current_id = p.lsp_wait.?.id; + p.update(.{ .lsp_resp = .{ .id = old_id, .rows = null } }); + try std.testing.expectEqual(current_id, p.lsp_wait.?.id); + p.update(.{ .lsp_resp = .{ .id = current_id, .rows = null } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + + p.lspRequest(p.active, .completion, ""); + p.update(.{ .lsp_resp = .{ .id = p.lsp_wait.?.id, .rows = "" } }); + try std.testing.expect(p.lsp_wait == null); + try std.testing.expectEqualStrings("x ", pane.file.?.content); + try std.testing.expectEqual(@as(i32, 4), pane.cur_col); +} + test "board heap: every allocation failure during boot is a clean OutOfMemory" { try std.testing.checkAllAllocationFailures( std.testing.allocator, diff --git a/src/pdf.zig b/src/pdf.zig index b7e12fe8..a12be0b1 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -726,6 +726,13 @@ pub const Document = struct { document.* = undefined; } + pub fn openBytes(bytes: []const u8) !Document { + var page_count: c_int = 0; + const handle = c.pardes_pdf_open_memory(bytes.ptr, bytes.len, &page_count) orelse + return error.OpenFailed; + return .{ .handle = handle, .pages = @intCast(page_count) }; + } + /// Load and flatten the PDF-native outline/bookmarks. MuPDF's temporary /// tree and the bridge's flat view are both dropped before this returns. pub fn outline( diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index bd72a54a..8e433fde 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -316,15 +316,17 @@ pardes_pdf_grown_capacity(size_t current, size_t needed, size_t maximum) return capacity; } -pardes_pdf_document * -pardes_pdf_open(const char *path, int *page_count) +static pardes_pdf_document * +pardes_pdf_open_source(const char *path, const unsigned char *bytes, size_t length, int *page_count) { pardes_pdf_document *state; fz_context *ctx; fz_document *doc = NULL; + fz_buffer *buffer = NULL; + fz_stream *stream = NULL; int pages = 0; - if (path == NULL || page_count == NULL) + if ((path == NULL && bytes == NULL) || page_count == NULL) return NULL; state = pardes_pdf_allocate_document(); @@ -344,14 +346,27 @@ pardes_pdf_open(const char *path, int *page_count) } fz_var(doc); + fz_var(buffer); + fz_var(stream); fz_try(ctx) { fz_register_document_handlers(ctx); - doc = fz_open_document(ctx, path); + if (path != NULL) { + doc = fz_open_document(ctx, path); + } else { + buffer = fz_new_buffer_from_copied_data(ctx, bytes, length); + stream = fz_open_buffer(ctx, buffer); + doc = fz_open_document_with_stream(ctx, "application/pdf", stream); + } pages = fz_count_pages(ctx, doc); if (pages < 1) fz_throw(ctx, FZ_ERROR_FORMAT, "PDF has no pages"); } + fz_always(ctx) + { + fz_drop_stream(ctx, stream); + fz_drop_buffer(ctx, buffer); + } fz_catch(ctx) { fz_report_error(ctx); @@ -369,6 +384,18 @@ pardes_pdf_open(const char *path, int *page_count) return state; } +pardes_pdf_document * +pardes_pdf_open(const char *path, int *page_count) +{ + return pardes_pdf_open_source(path, NULL, 0, page_count); +} + +pardes_pdf_document * +pardes_pdf_open_memory(const unsigned char *bytes, size_t length, int *page_count) +{ + return pardes_pdf_open_source(NULL, bytes, length, page_count); +} + void pardes_pdf_close(pardes_pdf_document *document) { diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index a3d2de89..81287578 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -172,6 +172,7 @@ enum { }; pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); +pardes_pdf_document *pardes_pdf_open_memory(const unsigned char *bytes, size_t length, int *page_count); void pardes_pdf_close(pardes_pdf_document *document); /* Page dimensions in PDF points after crop/rotation, without rasterizing. */ diff --git a/src/pdf_pane.zig b/src/pdf_pane.zig deleted file mode 100644 index 8f163488..00000000 --- a/src/pdf_pane.zig +++ /dev/null @@ -1,2763 +0,0 @@ -//! PDF panes: MuPDF-owned document state, continuous layout, navigation, -//! search/selection/outline behavior, raster reconciliation, native placement, -//! and the direct input/render seam to the shared Pardes grid. -//! Cross-pane placement and output ownership remain in pardes.zig. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const image = @import("image.zig"); -const config = @import("config.zig"); -const file_pane = @import("file_pane.zig"); -const look = @import("look.zig"); -const modal = @import("modal.zig"); -const output_pane = @import("output_pane.zig"); -const tracy = @import("tracy.zig"); -const normal_input = @import("normal_input.zig"); - -pub const enabled = @import("pardes_config").mupdf; -pub const pdf = if (enabled) @import("mupdf") else struct { - pub const PageSize = struct { width: f32, height: f32 }; - pub const Raster = struct { - width: usize = 0, - height: usize = 0, - stride: usize = 0, - len: usize = 0, - pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 }; - pub fn wholePage(_: @This()) Band { - return .{}; - } - pub fn band(_: @This(), _: usize, _: usize) Band { - return .{}; - } - }; -}; -pub const Point = if (enabled) pdf.Point else void; -pub const Quad = if (enabled) pdf.Quad else void; -pub const Document = if (enabled) pdf.Document else opaque {}; -pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void; -const raster_max = 256; -const raster_spare = 4; -pub const page_gap_px: u32 = 8; -const band_grain: usize = 64; - -pub const FitMode = if (enabled) enum { width, height } else void; -pub const TintMode = if (enabled) pdf.TintMode else void; -pub const TintColors = if (enabled) pdf.TintColors else void; -pub const RenderRequest = if (enabled) pdf.RenderRequest else void; - -/// Dump records must remain recognizable as PDFs even in a build without -/// MuPDF, where Look deliberately treats them as ordinary files. -pub fn isPath(path: []const u8) bool { - return std.ascii.endsWithIgnoreCase(path, ".pdf"); -} - -test "PDF dump paths are recognized independent of MuPDF support" { - try std.testing.expect(isPath("manual.pdf")); - try std.testing.expect(isPath("MANUAL.PDF")); - try std.testing.expect(!isPath("manual.pdf.txt")); - try std.testing.expect(!isPath("pdf")); -} - -pub const RasterPolicy = struct { - dpi: u16, - max_dimension: u16, - match_viewport: bool, -}; - -/// Recover the dynamic PDF prefix written by older version-1 dumps. Fit and -/// tint intentionally start fresh on restore, so matching the newly generated -/// prefix cannot recover a custom tail; the stable PdfSections marker and the -/// exact path delimit the old prefix without parsing renderer state. -pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { - if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; - const marker = " PdfSections "; - const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; - const path_at = marker_at + marker.len; - if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null; - return saved_tag[0 .. path_at + path.len]; -} - -test "legacy PDF prefix is delimited by its stable marker and exact path" { - const path = "/tmp/a document.pdf"; - const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del"; - try std.testing.expectEqualStrings( - "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path, - legacySavedPrefix(path, tag).?, - ); -} - -pub const TintKey = if (enabled) struct { - mode: TintMode, - colors: pdf.TintColors, - - pub fn eql(a: @This(), b: @This()) bool { - return a.mode == b.mode and - (a.mode == .disabled or std.meta.eql(a.colors, b.colors)); - } -} else void; - -pub const Highlight = if (enabled) pdf.Highlight else void; -pub const Highlights = if (enabled) struct { - items: []const Highlight, - /// Hover items occupy [0..active_start); search/selection follow them. - active_start: usize, - hover_page: ?usize, - - pub fn forPage(highlights: @This(), page: usize, active_page: usize) []const Highlight { - const hover = highlights.items[0..highlights.active_start]; - if (page == active_page) - return if (highlights.hover_page == page) - highlights.items - else - highlights.items[highlights.active_start..]; - return if (highlights.hover_page == page) hover else &.{}; - } -} else void; - -pub const HighlightInput = if (enabled) struct { - hover_quads: []const Quad = &.{}, - hover_page: ?usize = null, - hover_color: [3]u8, - selection_color: [3]u8, -} else void; - -pub fn buildHighlights( - state: *const State, - arena: std.mem.Allocator, - input: HighlightInput, -) !Highlights { - if (comptime !enabled) return; - const search_len = if (state.search_results) |results| results.quads.len else 0; - const selection_len = if (state.selection) |selection| selection.quads.len else 0; - const active_start = input.hover_quads.len; - const highlights = try arena.alloc(Highlight, active_start + search_len + selection_len); - var n: usize = 0; - for (input.hover_quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c }, - .custom, - ); - n += 1; - } - if (state.search_results) |results| { - for (results.quads) |item| { - highlights[n] = pdf.Highlight.init( - item.quad, - .{ 0xff, 0xd5, 0x4f, 0x70 }, - .search, - ); - n += 1; - } - } - if (state.selection) |selection| { - for (selection.quads) |quad| { - highlights[n] = pdf.Highlight.init( - quad, - .{ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78 }, - .selection, - ); - n += 1; - } - } - return .{ .items = highlights, .active_start = active_start, .hover_page = input.hover_page }; -} - -pub const Raster = if (enabled) struct { - valid: bool = false, - page: usize = 0, - rgba: []u8 = &.{}, - /// Full page shape; rgba contains only the band below. - iw: usize = 0, - ih: usize = 0, - band_y: usize = 0, - band_h: usize = 0, - request: pdf.RenderRequest = .{}, - request_valid: bool = false, - tried: bool = false, - decorated: bool = false, - tint_key: ?TintKey = null, - revision: u32 = 0, -} else void; - -pub const SectionsOutput = if (enabled) struct { - pane: usize, - serial: u32, - revision: u32, -} else void; - -pub const SelectionUpdate = enum { failed, stationary, unchanged, changed }; - -/// Plain owned state for one PDF pane. Document navigation, search, selection, -/// layout and raster behavior live beside it; Pardes retains only cross-pane -/// focus/dispatch, cell rectangles and surface attachment. -pub const State = if (enabled) struct { - path: []u8, - document: Document, - page: usize = 0, - page_count: usize, - page_sizes: []pdf.PageSize, - page_starts: []u64, - page_heights: []u32, - document_height: u64 = 0, - layout_viewport_w: u32 = 0, - layout_viewport_h: u32 = 0, - layout_fit: FitMode = .width, - layout_valid: bool = false, - document_scroll_y: f64 = 0, - scroll_to_page_pending: bool = true, - rasters: [raster_max]Raster = undefined, - rasters_len: usize = 0, - spare: [raster_spare][]u8 = @splat(&.{}), - spare_len: usize = 0, - layout_anchor_pending: bool = false, - layout_anchor_page: usize = 0, - layout_anchor_fraction: f64 = 0, - next_raster_revision: u32 = 0, - scroll_travel: f64 = 0, - fit: FitMode = .width, - tint: TintMode = .filtered, - pan_x: u16 = 0, - pan_y: u16 = 0, - highlights_dirty: bool = false, - search_reveal_pending: bool = false, - search_results: ?pdf.SearchResults = null, - selection: ?pdf.Selection = null, - selection_text: []u8 = &.{}, - selection_anchor: ?Point = null, - selection_head: ?Point = null, - drag_anchor: ?Point = null, - drag_head: ?Point = null, - text: []u8 = &.{}, - text_tried: bool = false, - text_scroll: usize = 0, - text_scroll_clamp_pending: bool = false, - search_query: []u8 = &.{}, - search_hit: usize = 0, - reveal_viewport_w: u32 = 0, - reveal_viewport_h: u32 = 0, - reveal_fit: FitMode = .width, - reveal_viewport_valid: bool = false, - outline: ?pdf.Outline = null, - outline_tried: bool = false, - sections_output: ?SectionsOutput = null, - outline_reveal_pending: ?pdf.OutlineInternalDestination = null, - - pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() { - var document = try Document.open(path); - errdefer document.deinit(); - const page_sizes = try gpa.alloc(pdf.PageSize, document.pages); - errdefer gpa.free(page_sizes); - for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page); - const page_starts = try gpa.alloc(u64, document.pages); - errdefer gpa.free(page_starts); - const page_heights = try gpa.alloc(u32, document.pages); - errdefer gpa.free(page_heights); - const owned_path = try gpa.dupe(u8, path); - errdefer gpa.free(owned_path); - return .{ - .path = owned_path, - .document = document, - .page = if (page_one_based > 0) - @min(page_one_based - 1, document.pages - 1) - else - 0, - .page_count = document.pages, - .page_sizes = page_sizes, - .page_starts = page_starts, - .page_heights = page_heights, - }; - } - - /// Reopen the file behind this pane without exposing a half-reloaded - /// document. MuPDF owns document-derived objects (pages, text, outlines, - /// selections and rendered pixels), so a live reload replaces the whole - /// State and carries over only user-facing view/configuration data. - pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void { - const preserve_anchor = !state.scroll_to_page_pending and - (state.layout_anchor_pending or - (state.layout_valid and state.page_count > 0 and state.document_height > 0)); - var anchor_page: usize = state.layout_anchor_page; - var anchor_fraction: f64 = state.layout_anchor_fraction; - if (preserve_anchor and !state.layout_anchor_pending) { - anchor_page = pageAtOffset(state, state.document_scroll_y); - const start: f64 = @floatFromInt(state.page_starts[anchor_page]); - const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page])); - anchor_fraction = std.math.clamp( - (state.document_scroll_y - start) / height, - 0, - 1, - ); - } - - var fresh = try @This().open(gpa, state.path, state.page + 1); - errdefer fresh.deinit(gpa); - if (state.search_query.len > 0) - fresh.search_query = try gpa.dupe(u8, state.search_query); - - fresh.fit = state.fit; - fresh.tint = state.tint; - fresh.pan_x = state.pan_x; - fresh.pan_y = state.pan_y; - fresh.text_scroll = state.text_scroll; - fresh.text_scroll_clamp_pending = true; - fresh.search_hit = state.search_hit; - fresh.highlights_dirty = fresh.search_query.len > 0; - fresh.search_reveal_pending = state.search_reveal_pending; - // A retained query is still active, but a reload is not a new request - // to center its hit. Preserve the viewport observation so only a real - // viewport/fit change re-arms revealSearch on the next frame. - fresh.reveal_viewport_w = state.reveal_viewport_w; - fresh.reveal_viewport_h = state.reveal_viewport_h; - fresh.reveal_fit = state.reveal_fit; - fresh.reveal_viewport_valid = state.reveal_viewport_valid; - // Revisions are part of the backend texture key. Resetting this counter - // while the pane serial stays live can alias a cached pre-reload page. - fresh.next_raster_revision = state.next_raster_revision; - fresh.sections_output = state.sections_output; - if (preserve_anchor) { - fresh.scroll_to_page_pending = false; - fresh.layout_anchor_pending = true; - fresh.layout_anchor_page = anchor_page; - fresh.layout_anchor_fraction = anchor_fraction; - } - - var old = state.*; - state.* = fresh; - old.deinit(gpa); - } - - pub fn invalidateRaster(state: *@This(), page: usize) void { - if (rasterForPage(state, page)) |raster| raster.tried = false; - } - - pub fn invalidateAllRasters(state: *@This()) void { - for (state.rasters[0..state.rasters_len]) |*raster| { - if (raster.valid) raster.tried = false; - } - } - - fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void { - if (rgba.len == 0) return; - if (state.spare_len == state.spare.len) return gpa.free(rgba); - state.spare[state.spare_len] = rgba; - state.spare_len += 1; - } - - fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { - state.retireRgba(gpa, raster.rgba); - raster.* = .{}; - } - - fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 { - for (state.spare[0..state.spare_len], 0..) |candidate, index| { - if (candidate.len != bytes) continue; - state.spare_len -= 1; - state.spare[index] = state.spare[state.spare_len]; - return candidate; - } - return gpa.alloc(u8, bytes) catch null; - } - - fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void { - while (state.spare_len > 1) { - state.spare_len -= 1; - gpa.free(state.spare[state.spare_len]); - } - } - - fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void { - if (state.search_results) |*results| results.deinit(gpa); - state.search_results = null; - } - - fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void { - if (state.selection) |*selection| selection.deinit(gpa); - state.selection = null; - if (state.selection_text.len > 0) gpa.free(state.selection_text); - state.selection_text = &.{}; - state.selection_anchor = null; - state.selection_head = null; - } - - /// Transactionally replace the word-snapped selection and its owned text. - /// `stationary` is reserved for the UI's pre-probe drag check; this state - /// operation returns only failed, unchanged, or changed. - pub fn setSelection( - state: *@This(), - gpa: std.mem.Allocator, - start: Point, - end: Point, - invalidate_raster: bool, - ) SelectionUpdate { - if (state.selection != null and - state.selection_anchor != null and state.selection_head != null and - state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and - state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged; - var selection = state.document.select(gpa, state.page, start, end) catch return .failed; - const text = state.document.copySelection( - gpa, - state.page, - selection.start, - selection.end, - ) catch { - selection.deinit(gpa); - return .failed; - }; - - state.dropSelection(gpa); - state.selection = selection; - state.selection_text = text; - state.selection_anchor = start; - state.selection_head = end; - if (invalidate_raster) state.invalidateRaster(state.page); - return .changed; - } - - pub fn clearDrag(state: *@This()) void { - state.drag_anchor = null; - state.drag_head = null; - } - - pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void { - const changed = state.selection != null or state.selection_text.len > 0; - state.dropSelection(gpa); - if (changed) state.invalidateRaster(state.page); - } - - /// Escape's cancel: everything transient a reader can SEE — the mouse - /// selection and the search overlay — and nothing that says WHERE in the - /// document they are. Page, scroll, fit and tint are what the pane is, not - /// chrome. Allocation-free, so it cannot half-cancel. - pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void { - state.clearDrag(); - state.clearSelection(gpa); - if (state.search_query.len == 0) return; - state.dropSearchQuery(gpa); - state.invalidateRaster(state.page); - } - - fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void { - state.dropSearchResults(gpa); - state.dropSelection(gpa); - state.clearDrag(); - if (state.text.len > 0) gpa.free(state.text); - state.text = &.{}; - state.text_tried = false; - state.text_scroll = 0; - state.text_scroll_clamp_pending = false; - state.highlights_dirty = state.search_query.len > 0; - state.search_reveal_pending = state.search_query.len > 0; - state.search_hit = 0; - } - - /// Forget the query, its hits, and every flag derived from them. Shared by - /// the cancel above and by the replacement below, which owns new bytes the - /// caller allocated before anything here was dropped. - fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void { - if (state.search_query.len > 0) gpa.free(state.search_query); - state.search_query = &.{}; - state.search_hit = 0; - state.dropSearchResults(gpa); - state.highlights_dirty = false; - state.search_reveal_pending = false; - } - - pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void { - if (std.mem.eql(u8, state.search_query, query)) return; - const owned = try gpa.dupe(u8, query); - state.dropSearchQuery(gpa); - state.search_query = owned; - state.highlights_dirty = query.len > 0; - state.search_reveal_pending = query.len > 0; - state.invalidateRaster(state.page); - } - - pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 { - if (!state.text_tried) { - state.text_tried = true; - state.text = state.document.pageText(gpa, state.page) catch &.{}; - } - if (state.text_scroll_clamp_pending) { - const lines = std.mem.count(u8, state.text, "\n") + 1; - state.text_scroll = @min(state.text_scroll, lines - 1); - state.text_scroll_clamp_pending = false; - } - return state.text; - } - - pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void { - if (!state.highlights_dirty) return; - state.highlights_dirty = false; - state.dropSearchResults(gpa); - if (state.search_query.len == 0) return; - const results = state.document.search(gpa, state.page, state.search_query) catch return; - state.search_hit = if (results.hit_count == 0) - 0 - else - @min(state.search_hit, results.hit_count - 1); - state.search_results = results; - } - - pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline { - if (!state.outline_tried) { - state.outline_tried = true; - state.outline = state.document.outline(gpa) catch null; - } - return if (state.outline) |*outline| outline else null; - } - - pub fn renderSections( - state: *@This(), - pdf_gpa: std.mem.Allocator, - output_gpa: std.mem.Allocator, - ) ![]u8 { - const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline| - outline.entries - else - &.{}; - return SectionRows.render(output_gpa, state.path, entries); - } - - pub fn sectionDestination( - state: *@This(), - gpa: std.mem.Allocator, - ordinal: usize, - ) ?pdf.OutlineDestination { - const outline = state.ensureOutline(gpa) orelse return null; - return SectionRows.resolve(outline.entries, ordinal); - } - - /// Apply the one-based page/hit location encoded in a PDF search row. - /// Returns whether host pane cursor chrome must be reset. - pub fn focusLocation( - state: *@This(), - gpa: std.mem.Allocator, - line: usize, - column: usize, - ) bool { - const changed = line > 0 and state.activatePage(gpa, line - 1, true); - if (column > 0 and state.search_query.len > 0) { - state.search_hit = column - 1; - state.search_reveal_pending = true; - } - return changed; - } - - /// Change the document page while leaving pane cursor/selection chrome to - /// the UI adapter. Returns whether that pane-local chrome must be reset. - pub fn activatePage( - state: *@This(), - gpa: std.mem.Allocator, - page: usize, - reveal: bool, - ) bool { - state.outline_reveal_pending = null; - const next = @min(page, state.page_count -| 1); - const changed = next != state.page; - if (changed) { - state.invalidatePage(gpa); - state.page = next; - } - if (reveal) { - state.scroll_to_page_pending = true; - if (state.layout_valid) { - state.document_scroll_y = @floatFromInt(state.page_starts[next]); - state.scroll_to_page_pending = false; - } - } else { - state.search_reveal_pending = false; - } - return changed; - } - - pub fn toggleFit(state: *@This()) void { - state.fit = if (state.fit == .width) .height else .width; - state.pan_x = 0; - state.pan_y = 0; - state.layout_valid = false; - state.scroll_to_page_pending = true; - state.search_reveal_pending = state.search_query.len > 0; - } - - pub fn toggleTint(state: *@This()) void { - state.tint = state.tint.next(); - state.invalidateAllRasters(); - } - - pub fn queueOutlineReveal( - state: *@This(), - destination: pdf.OutlineInternalDestination, - ) void { - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - state.outline_reveal_pending = destination; - } - - pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { - gpa.free(state.path); - for (state.rasters[0..state.rasters_len]) |raster| - if (raster.rgba.len > 0) gpa.free(raster.rgba); - for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); - gpa.free(state.page_sizes); - gpa.free(state.page_starts); - gpa.free(state.page_heights); - if (state.text.len > 0) gpa.free(state.text); - if (state.search_query.len > 0) gpa.free(state.search_query); - if (state.search_results) |*results| results.deinit(gpa); - if (state.selection) |*selection| selection.deinit(gpa); - if (state.selection_text.len > 0) gpa.free(state.selection_text); - if (state.outline) |*outline| outline.deinit(gpa); - state.document.deinit(); - state.* = undefined; - } -} else void; - -test "feature-off PDF state is zero-sized" { - if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State)); -} - -pub const SearchOutput = struct { - bytes: usize = 0, - rows: usize = 0, - anchor: ?usize = null, -}; - -pub fn textLines( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, -) ![]const []const u8 { - if (comptime !enabled) return &.{}; - const page_text = state.ensureText(gpa); - const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1); - var it = std.mem.splitScalar(u8, page_text, '\n'); - var n: usize = 0; - while (it.next()) |line| : (n += 1) lines[n] = line; - return lines; -} - -pub fn visibleText( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - max_rows: usize, -) ![]const u8 { - if (comptime !enabled) return ""; - const page_text = state.ensureText(gpa); - var start: usize = 0; - for (0..state.text_scroll) |_| { - const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse - return arena.dupe(u8, ""); - start = newline + 1; - } - if (max_rows == 0) return arena.dupe(u8, ""); - - var end = start; - var row: usize = 0; - while (row < max_rows) : (row += 1) { - const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse { - end = page_text.len; - break; - }; - if (row + 1 == max_rows) { - end = newline; - break; - } - end = newline + 1; - } - return arena.dupe(u8, page_text[start..end]); -} - -/// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The -/// same hit numbering drives persistent highlights and later reveal actions. -pub fn searchRows( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - pattern: []const u8, - from_cursor: bool, - out: []u8, -) !SearchOutput { - if (comptime !enabled) return .{}; - try state.setSearchQuery(gpa, pattern); - const shown = std.fs.path.basename(state.path); - var result: SearchOutput = .{}; - const max_hits = 512; - var snippet_len = @min(pattern.len, 200); - while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80) - snippet_len -= 1; - const snippet = pattern[0..snippet_len]; - for (0..state.page_count) |page| { - if (result.rows >= max_hits) break; - var found = try state.document.search(gpa, page, pattern); - defer found.deinit(gpa); - - const cursor_hit: ?usize = if (from_cursor and page == state.page) cursor: { - const selection = state.selection orelse break :cursor null; - for (found.quads) |item| { - const q = item.quad; - const center: Point = .{ - .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, - .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, - }; - if (selection.contains(center)) break :cursor item.hit; - } - break :cursor null; - } else null; - - for (0..found.hit_count) |hit_index| { - if (result.rows >= max_hits) break; - const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ - shown, page + 1, hit_index + 1, snippet, - }); - if (line.len > out.len - result.bytes) return result; - if (from_cursor and - (page < state.page or - (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?))) - result.anchor = result.rows; - @memcpy(out[result.bytes..][0..line.len], line); - result.bytes += line.len; - result.rows += 1; - } - } - return result; -} - -pub const Viewport = struct { pixel_w: u32, pixel_h: u32 }; -pub const VisiblePages = struct { first: usize = 0, len: usize = 0 }; -pub const PanAxis = enum { horizontal, vertical }; -pub const PanResult = enum { moved, edge, unavailable }; -pub const ScrollResult = struct { active_page: usize }; -pub const CellPixels = struct { w: u16, h: u16 }; -pub const NormalHost = enum { - none, - leader, - command_line, - search, - search_forward, - search_backward, -}; -pub const NormalResult = struct { - host: NormalHost = .none, - page_changed: bool = false, -}; - -const PlacedGeometry = struct { - geometry: image.NativeGeometry, - pixel_offset_y: f32, -}; - -pub const PlacedRaster = if (enabled) struct { - page: usize, - revision: u32, - fit: FitMode, - pan_x: u16, - geometry: image.NativeGeometry, - pixel_offset_y: f32, - rgba: []const u8, - width: usize, - band_height: usize, -} else void; - -const VisibleRows = struct { - base: image.NativeGeometry, - y0: u32, - y1: u32, - dst_y: u32, - dst_h: u32, - pixel_offset_y: f32, -}; - -pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { - if (comptime !enabled) return; - return .{ - .dpi = policy.dpi, - .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, - .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, - .max_dimension = policy.max_dimension, - }; -} - -fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 { - if (comptime !enabled) return 0; - if (fit == .height) return viewport.pixel_h; - const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) * - @as(f64, size.height) / @as(f64, size.width); - return @max(1, @as(u32, @intFromFloat(@min( - @as(f64, @floatFromInt(std.math.maxInt(u32))), - @round(scaled), - )))); -} - -fn pageAtOffset(state: *const State, offset: f64) usize { - if (comptime !enabled) return 0; - const y: u64 = @intFromFloat(std.math.clamp( - @floor(offset), - 0, - @as(f64, @floatFromInt(state.document_height -| 1)), - )); - var lo: usize = 0; - var hi: usize = state.page_count; - while (lo + 1 < hi) { - const mid = lo + (hi - lo) / 2; - if (state.page_starts[mid] <= y) lo = mid else hi = mid; - } - const end = state.page_starts[lo] + state.page_heights[lo]; - return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo; -} - -fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool { - if (comptime !enabled) return false; - const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; - const bottom = top + @as(f64, @floatFromInt(state.page_heights[page])); - return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h)); -} - -pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages { - if (comptime !enabled) return .{}; - var out: VisiblePages = .{}; - var page = pageAtOffset(state, state.document_scroll_y); - if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1; - out.first = page; - while (page < state.page_count) : (page += 1) { - const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y; - if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break; - if (pageVisible(state, page, viewport)) out.len += 1; - } - return out; -} - -fn visibleContains(visible: VisiblePages, page: usize) bool { - return page >= visible.first and page - visible.first < visible.len; -} - -pub fn rasterForPage(state: *State, page: usize) ?*Raster { - if (comptime !enabled) return null; - for (state.rasters[0..state.rasters_len]) |*raster| - if (raster.valid and raster.page == page) return raster; - return null; -} - -fn rasterForPageConst(state: *const State, page: usize) ?*const Raster { - if (comptime !enabled) return null; - for (state.rasters[0..state.rasters_len]) |*raster| - if (raster.valid and raster.page == page) return raster; - return null; -} - -fn visibleRows( - state: *const State, - viewport: Viewport, - page: usize, - iw: usize, - ih: usize, -) ?VisibleRows { - if (comptime !enabled) return null; - const page_h = state.page_heights[page]; - const base = image.nativeGeometry( - iw, - ih, - viewport.pixel_w, - page_h, - switch (state.fit) { - .width => .width, - .height => .height, - }, - state.pan_x, - 0, - ) orelse return null; - if (base.dst.h == 0 or base.src.h == 0) return null; - - const scroll_floor = @floor(state.document_scroll_y); - const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor); - const scroll_i: i64 = @intFromFloat(@min( - scroll_floor, - @as(f64, @floatFromInt(std.math.maxInt(i64))), - )); - const start_i: i64 = @intCast(@min( - state.page_starts[page], - @as(u64, std.math.maxInt(i64)), - )); - const full_y = start_i - scroll_i + @as(i64, base.dst.y); - const full_bottom = full_y + @as(i64, base.dst.h); - const visible_y = @max(@as(i64, 0), full_y); - const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom); - if (visible_bottom <= visible_y) return null; - - const rel_y0: u64 = @intCast(visible_y - full_y); - const rel_y1: u64 = @intCast(visible_bottom - full_y); - const src_y0: u32 = base.src.y + @as(u32, @intCast( - rel_y0 * base.src.h / base.dst.h, - )); - const src_y1: u32 = base.src.y + @as(u32, @intCast(@min( - @as(u64, base.src.h), - (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h, - ))); - if (src_y1 <= src_y0) return null; - return .{ - .base = base, - .y0 = src_y0, - .y1 = src_y1, - .dst_y = @intCast(visible_y), - .dst_h = @intCast(visible_bottom - visible_y), - .pixel_offset_y = -fractional, - }; -} - -fn placedGeometry( - state: *const State, - raster: *const Raster, - viewport: Viewport, - page: usize, -) ?PlacedGeometry { - if (comptime !enabled) return null; - const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null; - const band_y: u32 = @intCast(raster.band_y); - const band_end: u32 = @intCast(raster.band_y + raster.band_h); - if (rows.y0 < band_y or rows.y1 > band_end) return null; - return .{ - .geometry = .{ - .src = .{ - .x = rows.base.src.x, - .y = rows.y0 - band_y, - .w = rows.base.src.w, - .h = rows.y1 - rows.y0, - }, - .dst = .{ - .x = rows.base.dst.x, - .y = rows.dst_y, - .w = rows.base.dst.w, - .h = rows.dst_h, - }, - }, - .pixel_offset_y = rows.pixel_offset_y, - }; -} - -pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, page) orelse return null; - if (raster.rgba.len == 0) return null; - const placed = placedGeometry(state, raster, viewport, page) orelse return null; - return .{ - .page = page, - .revision = raster.revision, - .fit = state.fit, - .pan_x = state.pan_x, - .geometry = placed.geometry, - .pixel_offset_y = placed.pixel_offset_y, - .rgba = raster.rgba, - .width = raster.iw, - .band_height = raster.band_h, - }; -} - -pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, state.page) orelse return null; - const placed = placedGeometry(state, raster, viewport, state.page) orelse return null; - return placed.geometry; -} - -pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize { - if (comptime !enabled) return null; - if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null; - const document_y = state.document_scroll_y + local_y; - const page = pageAtOffset(state, document_y); - const start: f64 = @floatFromInt(state.page_starts[page]); - if (document_y < start or - document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null; - return page; -} - -pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool { - if (comptime !enabled) return false; - return placedRaster(state, viewport, page) != null; -} - -pub fn nativeReady(state: *const State, viewport: Viewport) bool { - if (comptime !enabled) return false; - for (state.rasters[0..state.rasters_len]) |*raster| { - if (raster.valid and raster.rgba.len > 0 and - placedGeometry(state, raster, viewport, raster.page) != null) return true; - } - return false; -} - -pub fn pointAtPage( - state: *const State, - viewport: Viewport, - page: usize, - px: i64, - py: i64, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const raster = rasterForPageConst(state, page) orelse return null; - if (raster.rgba.len == 0) return null; - const placed = placedGeometry(state, raster, viewport, page) orelse return null; - return pointAtGeometry( - placed.geometry, - placed.pixel_offset_y, - raster.iw, - raster.ih, - raster.band_y, - px, - py, - clamp_to_page, - ); -} - -fn slotShape(slot: *const Raster) pdf.Raster { - const stride = slot.iw * 4; - return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih }; -} - -fn flinging(state: *const State, viewport: Viewport) bool { - if (comptime !enabled) return false; - return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); -} - -fn wantedBand( - state: *const State, - viewport: Viewport, - page: usize, - shape: pdf.Raster, - is_flinging: bool, -) pdf.Raster.Band { - if (!is_flinging) return shape.wholePage(); - const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse - return shape.wholePage(); - const first = (@as(usize, rows.y0) / band_grain) * band_grain; - const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch - return shape.wholePage(); - return shape.band(first, last * band_grain - first); -} - -/// Keep exactly the visible page rasters resident and refresh only stale or -/// uncovered bands. Rendering is transactional: existing pixels remain -/// presentable until a replacement is fully rendered and tinted. -fn reconcile( - state: *State, - gpa: std.mem.Allocator, - request: RenderRequest, - tint_key: TintKey, - highlights: Highlights, - visible: VisiblePages, - viewport: Viewport, -) void { - if (comptime !enabled) return; - const tz = tracy.zone(@src(), "pdf.reconcile"); - defer tz.end(); - - // Remove first so arriving pages can claim departing page buffers. Only - // the final visible set can be seen, so a fling skips crossed-over pages. - var index: usize = 0; - while (index < state.rasters_len) { - if (visibleContains(visible, state.rasters[index].page)) { - index += 1; - continue; - } - state.retireRaster(gpa, &state.rasters[index]); - state.rasters_len -= 1; - if (index != state.rasters_len) - state.rasters[index] = state.rasters[state.rasters_len]; - } - - const is_flinging = flinging(state, viewport); - var page = visible.first; - const end = visible.first + visible.len; - while (page < end) : (page += 1) { - var raster = rasterForPage(state, page); - if (raster == null) { - if (state.rasters_len == state.rasters.len) continue; - state.rasters[state.rasters_len] = .{ .valid = true, .page = page }; - state.rasters_len += 1; - raster = &state.rasters[state.rasters_len - 1]; - } - const slot = raster.?; - const page_highlights = highlights.forPage(page, state.page); - const decorated = page_highlights.len > 0; - const stale = !slot.tried or !slot.request_valid or - !slot.request.eql(request) or slot.decorated != decorated or - slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or - slot.rgba.len == 0 or slot.band_h == 0; - const uncovered = !stale and uncovered: { - const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); - break :uncovered slot.band_y > want.y or - slot.band_y + slot.band_h < want.y + want.height; - }; - if (!stale and !uncovered) continue; - - slot.tried = true; - slot.request = request; - slot.request_valid = true; - const shape_or_null = shape: { - const tz_measure = tracy.zone(@src(), "pdf.measure"); - defer tz_measure.end(); - break :shape state.document.measureRenderAt(page, request) catch null; - }; - const shape = shape_or_null orelse continue; - const want = wantedBand(state, viewport, page, shape, is_flinging); - const fresh = state.claimRgba(gpa, want.len) orelse continue; - const filled = filled: { - { - const tz_render = tracy.zone(@src(), "pdf.render_into"); - defer tz_render.end(); - state.document.renderIntoAt( - page, - request, - shape, - want, - page_highlights, - fresh, - ) catch break :filled false; - } - const tz_tint = tracy.zone(@src(), "pdf.tint"); - defer tz_tint.end(); - pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch - break :filled false; - break :filled true; - }; - if (!filled) { - state.retireRgba(gpa, fresh); - continue; - } - - state.retireRgba(gpa, slot.rgba); - slot.rgba = fresh; - slot.iw = shape.width; - slot.ih = shape.height; - slot.band_y = want.y; - slot.band_h = want.height; - slot.decorated = decorated; - slot.tint_key = tint_key; - state.next_raster_revision +%= 1; - if (state.next_raster_revision == 0) state.next_raster_revision = 1; - slot.revision = state.next_raster_revision; - } - state.trimSpares(gpa); -} - -/// Resolve all pane-owned render decisions for one surface frame. The caller -/// supplies only backend policy, theme-derived tint/highlight colors, and the -/// viewport; it then transports the returned visible placements to Surface. -pub fn renderFrame( - state: *State, - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - viewport: Viewport, - policy: RasterPolicy, - tint_key: TintKey, - highlight_input: HighlightInput, -) VisiblePages { - if (comptime !enabled) return .{}; - ensureLayout(state, viewport); - state.resolveSearch(gpa); - const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{ - .items = &.{}, - .active_start = 0, - .hover_page = null, - }; - const request = renderRequest(viewport, policy); - var visible = visiblePages(state, viewport); - reconcile(state, gpa, request, tint_key, highlights, visible, viewport); - - rearmSearchReveal(state, viewport); - revealSearch(state, viewport, activeGeometry(state, viewport)); - visible = visiblePages(state, viewport); - reconcile(state, gpa, request, tint_key, highlights, visible, viewport); - return visible; -} - -pub fn normalizedPixel(value: f32, dimension: usize) u32 { - const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension)); - return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); -} - -pub fn scaledStep(base: u32, count: u32) u32 { - return @intCast(@min( - @as(u64, std.math.maxInt(u32)), - @as(u64, base) * @max(@as(u64, 1), count), - )); -} - -pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult { - if (comptime !enabled) return null; - if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll); - if (next == state.document_scroll_y) return null; - state.scroll_travel += @abs(next - state.document_scroll_y); - state.document_scroll_y = next; - return .{ .active_page = pageAtOffset(state, next) }; -} - -pub fn panPixels( - state: *State, - geometry: image.NativeGeometry, - axis: PanAxis, - direction: i8, - display_pixels: u32, -) PanResult { - if (comptime !enabled) return .unavailable; - const raster = rasterForPage(state, state.page) orelse return .unavailable; - const source_full: u32 = @intCast(switch (axis) { - .horizontal => raster.iw, - .vertical => raster.ih, - }); - const crop = switch (axis) { - .horizontal => geometry.src.w, - .vertical => geometry.src.h, - }; - const source_at = switch (axis) { - .horizontal => geometry.src.x, - .vertical => geometry.src.y, - }; - const displayed = @max(@as(u32, 1), switch (axis) { - .horizontal => geometry.dst.w, - .vertical => geometry.dst.h, - }); - const overflow = source_full -| crop; - if (overflow == 0 or - (direction < 0 and source_at == 0) or - (direction > 0 and source_at >= overflow)) return .edge; - - const source_step = @max( - @as(u64, 1), - (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, - ); - const normalized_step: u32 = @intCast(@min( - @as(u64, std.math.maxInt(u16)), - @max( - @as(u64, 1), - (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, - ), - )); - const position = switch (axis) { - .horizontal => &state.pan_x, - .vertical => &state.pan_y, - }; - if (direction > 0) { - position.* = @intCast(@min( - @as(u32, std.math.maxInt(u16)), - @as(u32, position.*) + normalized_step, - )); - } else { - position.* -|= @intCast(normalized_step); - } - return .moved; -} - -fn setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void { - if (comptime !enabled) return; - const raster = rasterForPage(state, state.page) orelse return; - if (raster.iw <= geometry.src.w) return; - state.pan_x = if (end) std.math.maxInt(u16) else 0; -} - -fn rearmSearchReveal(state: *State, viewport: Viewport) void { - if (comptime !enabled) return; - if (state.search_query.len == 0) return; - if (!state.reveal_viewport_valid or - state.reveal_viewport_w != viewport.pixel_w or - state.reveal_viewport_h != viewport.pixel_h or - state.reveal_fit != state.fit) - state.search_reveal_pending = true; -} - -pub fn revealSearch( - state: *State, - viewport: Viewport, - geometry: ?image.NativeGeometry, -) void { - if (comptime !enabled) return; - if (!state.search_reveal_pending) return; - state.reveal_viewport_w = viewport.pixel_w; - state.reveal_viewport_h = viewport.pixel_h; - state.reveal_fit = state.fit; - state.reveal_viewport_valid = true; - const results = state.search_results orelse { - state.search_reveal_pending = false; - return; - }; - if (results.hit_count == 0 or results.quads.len == 0) { - state.search_reveal_pending = false; - return; - } - state.search_hit = @min(state.search_hit, results.hit_count - 1); - const q = for (results.quads) |item| { - if (item.hit == state.search_hit) break item.quad; - } else { - state.search_reveal_pending = false; - return; - }; - state.search_reveal_pending = false; - const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; - const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; - if (state.fit == .width) { - ensureLayout(state, viewport); - const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) + - @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page])); - const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll); - return; - } - const placed = geometry orelse return; - const raster = rasterForPage(state, state.page) orelse return; - const full: u32 = @intCast(raster.iw); - const at = normalizedPixel(center_x, raster.iw); - if (at >= placed.src.x and at < placed.src.x + placed.src.w) return; - const overflow = full -| placed.src.w; - if (overflow == 0) return; - const wanted = @min(overflow, at -| placed.src.w / 2); - state.pan_x = @intCast( - (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, - ); -} - -pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool { - const current: i64 = @intCast(state.page); - const last: i64 = @intCast(state.page_count -| 1); - return state.activatePage( - gpa, - @intCast(std.math.clamp(current + delta, 0, last)), - true, - ); -} - -fn scrollNormal( - state: *State, - gpa: std.mem.Allocator, - viewport: ?Viewport, - delta_pixels: f64, -) bool { - const view = viewport orelse return false; - ensureLayout(state, view); - const result = scrollDocument(state, view, delta_pixels) orelse return false; - return result.active_page != state.page and - state.activatePage(gpa, result.active_page, false); -} - -fn moveRows( - state: *State, - gpa: std.mem.Allocator, - native_images: bool, - viewport: ?Viewport, - cell_pixels: CellPixels, - direction: i8, - count: u32, -) bool { - if (!native_images) { - const pages: i64 = @intCast(@max(@as(u32, 1), count)); - return stepPage(state, gpa, if (direction > 0) pages else -pages); - } - return scrollNormal( - state, - gpa, - viewport, - @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction, - ); -} - -fn movePage( - state: *State, - gpa: std.mem.Allocator, - native_images: bool, - viewport: ?Viewport, - cell_pixels: CellPixels, - direction: i8, - kind: normal_input.Page, - count: u32, -) bool { - if (!native_images) { - const pages: i64 = @intCast(@max(@as(u32, 1), count)); - return stepPage(state, gpa, if (direction > 0) pages else -pages); - } - const base: u32 = switch (kind) { - .half_down, .half_up => if (viewport) |view| - @max(@as(u32, 1), view.pixel_h / 2) - else - cell_pixels.h, - .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h, - }; - return scrollNormal( - state, - gpa, - viewport, - @as(f64, @floatFromInt(scaledStep(base, count))) * direction, - ); -} - -/// Apply the PDF-owned portion of one parsed normal-mode action. The result -/// carries only host UI work; document page/search/pan/scroll state is updated -/// here directly from plain inputs. -pub fn applyNormal( - state: *State, - gpa: std.mem.Allocator, - semantic: normal_input.Action, - native_images: bool, - cell_pixels: CellPixels, - viewport: ?Viewport, - geometry: ?image.NativeGeometry, -) NormalResult { - if (comptime !enabled) return .{}; - var result: NormalResult = .{}; - switch (semantic) { - // Escape stays in the document and cancels what is drawn over it. The - // way OUT of a PDF is Shift-Escape, which the host takes before this - // parse ever runs — a reader who pressed Escape to drop a selection - // was not asking to be moved to another pane. - .escape => state.cancelChrome(gpa), - .move => |move| switch (move.motion) { - .down => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - move.count, - ), - .up => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - move.count, - ), - .left => if (native_images) if (geometry) |placed| { - _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count)); - }, - .right => if (native_images) if (geometry) |placed| { - _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count)); - }, - else => {}, - }, - .goto => |go| switch (go.target) { - .file_start => result.page_changed = state.activatePage( - gpa, - if (go.explicit_count) go.count -| 1 else 0, - true, - ), - .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true), - .line_start, .first_nonws => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, false), - .line_end => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, true), - .line_down => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - go.count, - ), - .line_up => result.page_changed = moveRows( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - go.count, - ), - else => {}, - }, - .line => |line| switch (line) { - .start, .first_nonws => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, false), - .end => if (native_images) if (geometry) |placed| - setHorizontalEdge(state, placed, true), - }, - .goto_line => |go| { - if (go.explicit) - result.page_changed = state.activatePage(gpa, go.count -| 1, true); - }, - .page => |page| result.page_changed = switch (page.kind) { - .half_down, .down => movePage( - state, - gpa, - native_images, - viewport, - cell_pixels, - 1, - page.kind, - page.count, - ), - .half_up, .up => movePage( - state, - gpa, - native_images, - viewport, - cell_pixels, - -1, - page.kind, - page.count, - ), - }, - .view => |view| result.page_changed = switch (view) { - .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1), - .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1), - else => false, - }, - .leader => result.host = .leader, - .command_line => result.host = .command_line, - .search => result.host = .search, - .search_step => |direction| result.host = if (direction == .forward) - .search_forward - else - .search_backward, - else => {}, - } - return result; -} - -pub fn captureLayoutAnchor(state: *State) void { - if (comptime !enabled) return; - if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return; - const page = pageAtOffset(state, state.document_scroll_y); - const start: f64 = @floatFromInt(state.page_starts[page]); - const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page])); - state.layout_anchor_page = page; - state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1); - state.layout_anchor_pending = true; -} - -fn consumeOutlineReveal(state: *State, viewport: Viewport) void { - const destination = state.outline_reveal_pending orelse return; - state.outline_reveal_pending = null; - const page = @min(destination.page, state.page_count -| 1); - const size = state.page_sizes[page]; - const raw_y = destination.y orelse 0; - const y = if (std.math.isFinite(raw_y)) std.math.clamp(raw_y, 0, size.height) else 0; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + - @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page])); - - if (destination.x) |raw_x| if (state.fit == .height and std.math.isFinite(raw_x)) { - const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) * - @as(f64, size.width) / @as(f64, size.height); - const viewport_width: f64 = @floatFromInt(viewport.pixel_w); - if (display_width > viewport_width) { - const x = std.math.clamp(raw_x, 0, size.width); - const target = @as(f64, x) / @as(f64, size.width) * display_width; - const overflow = display_width - viewport_width; - const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow); - state.pan_x = @intFromFloat(@round( - wanted / overflow * @as(f64, std.math.maxInt(u16)), - )); - } - }; - state.search_reveal_pending = false; - state.reveal_viewport_w = viewport.pixel_w; - state.reveal_viewport_h = viewport.pixel_h; - state.reveal_fit = state.fit; - state.reveal_viewport_valid = true; -} - -pub fn ensureLayout(state: *State, viewport: Viewport) void { - if (comptime !enabled) return; - const tz = tracy.zone(@src(), "pdf.ensure_layout"); - defer tz.end(); - if (state.layout_valid and !state.scroll_to_page_pending and - !state.layout_anchor_pending and - (state.layout_viewport_w != viewport.pixel_w or - state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state); - if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or - state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit) - { - var at: u64 = 0; - for (state.page_sizes, 0..) |size, page| { - state.page_starts[page] = at; - const height = pageHeight(size, viewport, state.fit); - state.page_heights[page] = height; - at = std.math.add(u64, at, height) catch std.math.maxInt(u64); - if (page + 1 < state.page_count) - at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64); - } - state.document_height = at; - state.layout_viewport_w = viewport.pixel_w; - state.layout_viewport_h = viewport.pixel_h; - state.layout_fit = state.fit; - state.layout_valid = true; - if (state.scroll_to_page_pending) { - state.document_scroll_y = @floatFromInt(state.page_starts[state.page]); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - } else if (state.layout_anchor_pending) { - const page = @min(state.layout_anchor_page, state.page_count -| 1); - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) + - state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); - state.layout_anchor_pending = false; - } - } - consumeOutlineReveal(state, viewport); - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll); -} - -/// The owned result of asking MuPDF for the word at one page-space point. -/// It deliberately carries no pane state: callers may retain it for a hover -/// or consume it for Look without installing a user selection or activating a -/// page. Quads and text have separate lifetimes in MuPDF, and remain separate -/// here rather than making a transient hover impersonate `pdf.Selection`. -pub const WordProbe = if (enabled) struct { - page: usize, - quads: []pdf.Quad, - text: []u8, - - pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { - gpa.free(probe.quads); - gpa.free(probe.text); - probe.* = undefined; - } -} else void; - -/// Complete state of one native PDF pointer gesture. Core stores this beside -/// its generic drag routing; every PDF-specific transition is implemented by -/// pointerStart/pointerUpdate/pointerRelease below. The feature-off spelling -/// is deliberately empty so a build without MuPDF carries no latent UI state. -pub const PointerDrag = if (enabled) struct { - native: bool = false, - /// Right-button Look probes this cell on release without borrowing or - /// replacing the pane's persistent MuPDF selection. - word_at: ?struct { col: u16, row: u16 } = null, - /// Drag updates keep selection geometry/text live, but their expensive - /// baked raster highlight is committed once on release. - selection_changed: bool = false, -} else struct {}; - -pub const PointerAction = enum { look, exec }; - -/// Plain post-transaction work for the core. `text` is either pane-owned -/// selection text or a slice owned by `probe`; both stay valid through the -/// immediate builtin dispatch. Only the independent probe allocation is -/// released afterward, so Exec may delete the source pane safely. -pub const PointerRelease = if (enabled) struct { - action: ?PointerAction = null, - text: []const u8 = &.{}, - probe: ?WordProbe = null, - - pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void { - if (release.probe) |*probe| probe.deinit(gpa); - release.* = undefined; - } -} else struct { - pub fn deinit(_: *@This(), _: std.mem.Allocator) void {} -}; - -/// Word-snap a point and copy its text without changing document or UI state. -/// Empty MuPDF selections are reported as `null`; every non-null result owns -/// both slices and must be deinitialized by the caller. -pub fn probeWord( - document: *Document, - gpa: std.mem.Allocator, - page: usize, - point: if (enabled) pdf.Point else void, -) !?WordProbe { - if (comptime !enabled) return null; - var selection = try document.select(gpa, page, point, point); - errdefer selection.deinit(gpa); - const text = try document.copySelection(gpa, page, selection.start, selection.end); - errdefer gpa.free(text); - if (selection.quads.len == 0 or text.len == 0) { - selection.deinit(gpa); - gpa.free(text); - return null; - } - // Transfer the quad allocation out of Selection. There is intentionally - // no Selection.deinit on this success path: WordProbe is now its owner. - return .{ .page = page, .quads = selection.quads, .text = text }; -} - -/// Invert the exact native placement of one retained raster band. The -/// destination is shifted by `pixel_offset_y` at presentation time, while its -/// source y is local to the retained band; both adjustments happen here before -/// returning normalized full-page coordinates. -pub fn pointAtGeometry( - geometry: image.NativeGeometry, - pixel_offset_y: f32, - full_width: usize, - full_height: usize, - band_y: usize, - pixel_x: i64, - pixel_y: i64, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - if (full_width == 0 or full_height == 0 or - geometry.src.w == 0 or geometry.src.h == 0 or - geometry.dst.w == 0 or geometry.dst.h == 0) return null; - - const left: f64 = @floatFromInt(geometry.dst.x); - const top: f64 = @floatFromInt(geometry.dst.y); - const right = left + @as(f64, @floatFromInt(geometry.dst.w)); - const bottom = top + @as(f64, @floatFromInt(geometry.dst.h)); - var x: f64 = @floatFromInt(pixel_x); - var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y); - if (clamp_to_page) { - // Half-open rectangles: keep a clamped point infinitesimally inside - // the last presented pixel instead of letting it become `right`. - const epsilon = 1.0 / 1024.0; - x = std.math.clamp(x, left, @max(left, right - epsilon)); - y = std.math.clamp(y, top, @max(top, bottom - epsilon)); - } else if (x < left or x >= right or y < top or y >= bottom) { - return null; - } - - const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) + - (x - left) * @as(f64, @floatFromInt(geometry.src.w)) / - @as(f64, @floatFromInt(geometry.dst.w)); - const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) + - (y - top) * @as(f64, @floatFromInt(geometry.src.h)) / - @as(f64, @floatFromInt(geometry.dst.h)); - const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f)))); - const band_source_y: usize = @intFromFloat(@floor(source_y_f)); - const source_y = @min(full_height - 1, band_y + band_source_y); - return .{ - .x = (@as(f32, @floatFromInt(source_x)) + 0.5) / - @as(f32, @floatFromInt(full_width)), - .y = (@as(f32, @floatFromInt(source_y)) + 0.5) / - @as(f32, @floatFromInt(full_height)), - }; -} - -// ---- core seam ---------------------------------------------------------- -// -// These functions own everything a pane does because its payload is a PDF. -// Pardes supplies the shared layout rectangle, allocators and Surface; this -// module changes the PDF state and paints its native attachments directly. -// Cross-pane placement remains a tiny Pardes primitive; the PDF-specific -// +PdfSections ownership and Look adapter live here beside their state. - -pub fn openPane( - core: *pardes.Pardes, - id: usize, - path: []const u8, - page_one_based: usize, -) !*pardes.Pane { - if (comptime !enabled) return error.PdfDisabled; - var state = try State.open(core.pdf_gpa, path, page_one_based); - errdefer state.deinit(core.pdf_gpa); - const pane = try core.newDocPane(id); - pane.pdf = state; - pane.cur_pinned = true; - core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); - return pane; -} - -/// Release a PDF payload while its pane slot is still installed, so the host -/// can retire the corresponding directory watch before that id is reused. -pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void { - if (comptime !enabled) return; - for (core.panes, 0..) |slot, id| { - if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); - } - state.deinit(core.pdf_gpa); -} - -/// Reopen one live pane after its watched path changed. MuPDF deliberately -/// reopens the pathname so it can retain random access without requiring a -/// native watcher to allocate and copy the whole document first. -pub fn reloadPane( - core: *pardes.Pardes, - pane: *pardes.Pane, -) !void { - if (comptime !enabled) return error.PdfDisabled; - const state = &(pane.pdf orelse return error.MissingPdfState); - try state.reload(core.pdf_gpa); - resetPageChrome(pane); -} - -pub fn isSectionsOutput(pane: *const pardes.Pane) bool { - if (comptime !enabled) return false; - const file = pane.file orelse return false; - const output = file.output orelse return false; - return switch (output.from) { - .cmd => |builtin| builtin == .PdfSections, - else => false, - }; -} - -/// Refresh the exact generated outline buffer owned by this document. Content -/// revision is the ownership boundary: once a user edits it, live reload must -/// never overwrite it even if the slot and origin still look familiar. -fn refreshCleanSections(core: *pardes.Pardes, state: *State) void { - if (comptime !enabled) return; - const remembered = state.sections_output orelse return; - if (remembered.pane >= core.panes.len) return; - const result = core.panes[remembered.pane] orelse return; - if (result.serial != remembered.serial or !isSectionsOutput(result)) return; - const file = &result.file.?; - if (file.revision != remembered.revision) return; - - const content = state.renderSections(core.pdf_gpa, core.gpa) catch { - state.sections_output = null; - return; - }; - if (std.mem.eql(u8, file.content, content)) { - core.gpa.free(content); - return; - } - core.invalidateLookHover(remembered.pane); - file_pane.setContent(core, file, content); - const rows = file_pane.lineCount(file.content); - file.scroll = @min(file.scroll, rows - 1); - const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1); - result.cur_row = @intCast(row); - result.cur_col = @intCast(@min( - @as(usize, @intCast(@max(0, result.cur_col))), - modal.lineSlice(file.content, row).len, - )); - result.msel.active = false; - result.vsel.active = false; - result.nsel = 0; - state.sections_output.?.revision = file.revision; -} - -/// One successful watched-path transaction, including every piece of derived -/// PDF output. The caller owns generic update bookkeeping and status text. -pub fn reloadWatched( - core: *pardes.Pardes, - pane: *pardes.Pane, -) !void { - try reloadPane(core, pane); - refreshCleanSections(core, &pane.pdf.?); -} - -fn rearmCleanSections( - core: *pardes.Pardes, - id: usize, - pane: *pardes.Pane, - state: *State, -) bool { - const remembered = state.sections_output orelse return false; - if (remembered.pane >= core.panes.len) return false; - const result = core.panes[remembered.pane] orelse return false; - if (result.serial != remembered.serial or !isSectionsOutput(result)) return false; - const file = &result.file.?; - if (file.revision != remembered.revision) return false; - - file.scroll = 0; - result.cur_row = 0; - result.cur_col = 0; - result.msel.active = false; - result.vsel.active = false; - result.nsel = 0; - pane.search_pane = remembered.pane; - pane.search_row = null; - core.armLookWalk(remembered.pane); - core.active = id; - return true; -} - -/// Lazily materialise this pane's cached outline as a location list. All -/// PDF-specific ownership stays here; Pardes contributes only placement. -pub fn openSections(core: *pardes.Pardes, id: usize) void { - if (comptime !enabled) return; - const pane = core.panes[id] orelse return; - const state = &(pane.pdf orelse return); - if (rearmCleanSections(core, id, pane, state)) return; - const content = state.renderSections(core.pdf_gpa, core.gpa) catch return; - - const free = core.freeSlot() orelse { - core.gpa.free(content); - return; - }; - const dir = std.fs.path.dirname(state.path) orelse "/"; - const result = output_pane.open( - core, - free, - dir, - .{ .cmd = .PdfSections }, - "", - content, - ) catch { - core.gpa.free(content); - return; - }; - state.sections_output = .{ - .pane = free, - .serial = result.serial, - .revision = result.file.?.revision, - }; - core.placeDoc(id, free, result); - core.computeGeom(); - core.active = id; - pane.search_pane = free; - pane.search_row = null; - core.armLookWalk(free); -} - -const SectionsOwner = struct { - id: usize, - pane: *pardes.Pane, - state: *State, -}; - -/// Reverse the state-side ownership token. Merely having +PdfSections origin -/// is insufficient: an output may outlive its document, and duplicate panes -/// may legitimately have the same pathname. -fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner { - const output = core.panes[output_id] orelse return null; - if (!isSectionsOutput(output)) return null; - const file = output.file.?; - for (core.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const state = if (pane.pdf) |*pdf_state| pdf_state else continue; - const token = state.sections_output orelse continue; - if (token.pane == output_id and token.serial == output.serial and - token.revision == file.revision) - return .{ .id = id, .pane = pane, .state = state }; - } - return null; -} - -/// Interpret the ordinal column used only by a live, exact +PdfSections -/// output. Stale/orphaned outputs are consumed inertly rather than resolving -/// their old ordinal against a different document generation. -pub fn lookSection( - core: *pardes.Pardes, - output_id: usize, - path: []const u8, - at: look.Spot, -) bool { - if (comptime !enabled) return false; - const output = core.panes[output_id] orelse return false; - if (!isSectionsOutput(output)) return false; - if (at.line == 0 or at.col == 0 or at.end_line != 0 or - !look.isPdfPath(path)) return false; - const owner = sectionsOwner(core, output_id) orelse return true; - - var joined_path: [4096]u8 = undefined; - const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/"; - const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/"; - const target_path = if (std.fs.path.isAbsolute(path)) - path - else - std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true; - if (!std.mem.eql(u8, owner.state.path, target_path)) return true; - - const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true; - switch (destination) { - .internal => |internal| revealOutlineDestination(core, owner.pane, internal), - .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }), - .none => unreachable, - } - core.active = owner.id; - return true; -} - -pub fn resetPageChrome(pane: *pardes.Pane) void { - pane.cur_row = 0; - pane.cur_col = 0; - pane.vsel.active = false; - pane.msel.active = false; - pane.nsel = 0; -} - -pub fn activatePage( - core: *pardes.Pardes, - pane: *pardes.Pane, - page: usize, - reveal: bool, -) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane); -} - -pub fn revealOutlineDestination( - core: *pardes.Pardes, - pane: *pardes.Pane, - destination: OutlineInternalDestination, -) void { - if (comptime !enabled) return; - activatePage(core, pane, destination.page, false); - const state = &pane.pdf.?; - state.queueOutlineReveal(destination); - // Consume immediately when geometry already exists; otherwise the PDF - // draw pass consumes the same value after the next layout transaction. - _ = ensurePaneLayout(core, pane); -} - -pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void { - activatePage(core, pane, page, true); -} - -pub fn toggleFit(pane: *pardes.Pane) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - state.toggleFit(); -} - -pub fn toggleTint(pane: *pardes.Pane) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - state.toggleTint(); -} - -pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void { - if (comptime !enabled) return; - const state = &(pane.pdf orelse return); - if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane); -} - -pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport { - if (comptime !enabled) return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const cols = rect.w -| config.GUTTER; - const rows = rect.h -| pardes.BOX_H; - if (cols == 0 or rows == 0) return null; - return .{ - .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w), - .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h), - }; -} - -pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport { - if (comptime !enabled) return null; - const state = &(pane.pdf orelse return null); - const view = paneViewport(core, pane) orelse return null; - ensureLayout(state, view); - return view; -} - -pub fn tintColors(core: *const pardes.Pardes) TintColors { - if (comptime !enabled) return; - const theme = core.theme(); - return .{ - .background = theme.bg orelse theme.tag_bg, - .foreground = theme.fg orelse theme.tag_fg, - }; -} - -pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry { - if (comptime !enabled) return null; - const state = if (pane.pdf) |*view| view else return null; - const view = paneViewport(core, pane) orelse return null; - return activeGeometry(state, view); -} - -pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize { - if (comptime !enabled) return null; - const state = pane.pdf orelse return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null; - const local_y = @as(f64, @floatFromInt( - @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2, - )); - return pageAtViewportY(&state, local_y); -} - -pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool { - if (comptime !enabled) return false; - if (!core.native_images) return false; - const state = if (pane.pdf) |*view| view else return false; - const view = paneViewport(core, pane) orelse return false; - return nativeReady(state, view); -} - -pub fn nativePageAtGridRow( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - row: u16, -) ?usize { - if (comptime !enabled) return null; - if (!core.native_images) return null; - const state = if (pane.pdf) |*view| view else return null; - const page = pageAtGridRow(core, pane, row) orelse return null; - const view = paneViewport(core, pane) orelse return null; - if (!pageReady(state, view, page)) return null; - return page; -} - -pub fn pointAt( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - col: u16, - row: u16, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const state = pane.pdf orelse return null; - return panePointAtPage(core, pane, state.page, col, row, clamp_to_page); -} - -pub fn panePointAtPage( - core: *const pardes.Pardes, - pane: *const pardes.Pane, - page: usize, - col: u16, - row: u16, - clamp_to_page: bool, -) ?Point { - if (comptime !enabled) return null; - const state = if (pane.pdf) |*view| view else return null; - const view = paneViewport(core, pane) orelse return null; - const rect = for (core.panes, 0..) |slot, id| { - if (slot == pane) break core.rects[id]; - } else return null; - const body_x = @as(i64, rect.x + config.GUTTER); - const body_y = @as(i64, if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H); - const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2; - const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2; - return pointAtPage(state, view, page, px, py, clamp_to_page); -} - -pub fn probeAt( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) ?WordProbe { - if (comptime !enabled) return null; - const page = nativePageAtGridRow(core, pane, row) orelse return null; - const point = panePointAtPage(core, pane, page, col, row, false) orelse return null; - const state = &(pane.pdf orelse return null); - return probeWord(&state.document, core.pdf_gpa, page, point) catch null; -} - -pub fn beginDrag( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, - snap_word: bool, -) bool { - if (comptime !enabled) return false; - const hit_page = pageAtGridRow(core, pane, row) orelse return false; - if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false); - const state = &pane.pdf.?; - state.clearDrag(); - const point = pointAt(core, pane, col, row, false) orelse return false; - state.drag_anchor = point; - state.drag_head = point; - if (!snap_word) return true; - if (state.selection) |selection| if (selection.contains(point)) return true; - if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) { - // Preserve the old selection transactionally, but never let an - // outside click execute its stale text. - state.clearDrag(); - return false; - } - return true; -} - -pub fn beginSelection( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) bool { - return beginDrag(core, pane, col, row, true); -} - -pub fn updateSelection( - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, - invalidate_raster: bool, -) SelectionUpdate { - if (comptime !enabled) return .failed; - const state = &(pane.pdf orelse return .failed); - const anchor = state.drag_anchor orelse return .failed; - const point = pointAt(core, pane, col, row, true) orelse return .failed; - if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary; - const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster); - if (result != .failed) state.drag_head = point; - return result; -} - -/// Begin the native portion of a generic pointer gesture. Tag clicks and PDF -/// panes without a presentable raster remain ordinary grid gestures. A Look -/// click defers its side-effect-free word probe until release; select/Exec -/// establish the persistent selection transaction immediately. -pub fn pointerStart( - core: *pardes.Pardes, - pane: *pardes.Pane, - button: pardes.Mouse.Button, - col: u16, - row: u16, - on_tag: bool, -) PointerDrag { - if (comptime !enabled) return .{}; - if (on_tag or !paneNativeReady(core, pane)) return .{}; - var drag: PointerDrag = .{ .native = true }; - if (button == config.look_button) { - drag.word_at = .{ .col = col, .row = row }; - } else if (button == config.select_button) { - _ = beginDrag(core, pane, col, row, false); - } else if (button == config.exec_button) { - _ = beginDrag(core, pane, col, row, true); - } - return drag; -} - -/// Advance selection state without baking a new raster. A right-click stays a -/// pure word probe until it crosses into a second grid cell; at that point it -/// becomes the same native selection drag as Exec. -pub fn pointerUpdate( - drag: *PointerDrag, - core: *pardes.Pardes, - pane: *pardes.Pane, - col: u16, - row: u16, -) void { - if (comptime !enabled) return; - if (!drag.native) return; - if (drag.word_at) |at| { - if (col == at.col and row == at.row) return; - if (!beginDrag(core, pane, at.col, at.row, false)) { - drag.word_at = null; - return; - } - switch (updateSelection(core, pane, col, row, false)) { - .failed => { - drag.word_at = null; - pane.pdf.?.clearDrag(); - }, - // Adjacent grid cells can still address one raster pixel. Keep - // click mode and retry farther out. - .stationary => {}, - .unchanged => drag.word_at = null, - .changed => { - drag.word_at = null; - drag.selection_changed = true; - }, - } - return; - } - switch (updateSelection(core, pane, col, row, false)) { - .failed => pane.pdf.?.clearDrag(), - .stationary, .unchanged => {}, - .changed => drag.selection_changed = true, - } -} - -/// Cancel a native gesture before release (the acme 2-3 / 3-2 chord). Word -/// probes own nothing until release; changed selections still need their one -/// raster commit before their transient anchors are dropped. -pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void { - if (comptime !enabled) return; - if (drag.native) if (pane.pdf) |*state| { - if (drag.selection_changed) state.invalidateRaster(state.page); - state.clearDrag(); - }; -} - -/// Finalize every pane mutation before returning command work to the core. -/// The caller may dispatch `action` immediately and then call deinit; no path -/// after this function needs the source pane to remain alive. -pub fn pointerRelease( - core: *pardes.Pardes, - pane: *pardes.Pane, - drag: PointerDrag, - button: pardes.Mouse.Button, - chorded: bool, -) PointerRelease { - if (comptime !enabled) return .{}; - const state = &(pane.pdf orelse return .{}); - const slot = @intFromEnum(button); - const grid_selection = pane.sel[slot]; - pane.sel[slot].state = .none; // native quads, not projected text cells - - // Drag updates changed live geometry/text while leaving baked pixels - // stable. Commit exactly once before any chord/Exec/Look can clear state. - if (drag.selection_changed) state.invalidateRaster(state.page); - if (chorded) { - state.clearDrag(); - return .{}; - } - if (drag.word_at) |at| { - const maybe_probe = probeAt(core, pane, at.col, at.row); - state.clearDrag(); - const probe = maybe_probe orelse return .{}; - // A neighboring visible page becomes current before Look dispatch. - // WordProbe owns its text/quads independently of that state change. - if (probe.page != state.page) activatePage(core, pane, probe.page, false); - const text = probe.text; - return .{ .action = .look, .text = text, .probe = probe }; - } - if (button == config.select_button) { - const dragged = grid_selection.c0 != grid_selection.c1 or - grid_selection.r0 != grid_selection.r1; - if (!dragged) state.clearSelection(core.pdf_gpa); - pane.cur_pinned = true; - pane.mode = .normal; - pane.msel.active = false; - pane.vsel.active = false; - pane.pending = 0; - pane.nsel = 0; - state.clearDrag(); - return .{}; - } - if (state.drag_anchor == null or state.selection_text.len == 0) { - state.clearDrag(); - return .{}; - } - const text = state.selection_text; - state.clearDrag(); - return .{ - .action = if (button == config.look_button) .look else .exec, - .text = text, - }; -} - -pub fn highlightInput( - core: *pardes.Pardes, - pane_id: usize, - pane: *const pardes.Pane, -) HighlightInput { - if (comptime !enabled) return; - const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview| - if (preview.pane == pane_id and preview.serial == pane.serial) - preview.probe.quads - else - &.{} - else - &.{}; - const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null; - const selection_color = core.theme().sel_bg; - return .{ - .hover_quads = hover_quads, - .hover_page = hover_page, - .hover_color = selection_color, - .selection_color = selection_color, - }; -} - -pub fn panPane( - core: *pardes.Pardes, - pane: *pardes.Pane, - axis: PanAxis, - direction: i8, - display_pixels: u32, -) PanResult { - if (comptime !enabled) return .unavailable; - const placed = paneGeometry(core, pane) orelse return .unavailable; - const state = &(pane.pdf orelse return .unavailable); - return panPixels(state, placed, axis, direction, display_pixels); -} - -pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool { - if (comptime !enabled) return false; - const tz = tracy.zone(@src(), "pdf.scroll_notch"); - defer tz.end(); - const state = &(pane.pdf orelse return false); - const view = ensurePaneLayout(core, pane) orelse return false; - const result = scrollDocument(state, view, delta_pixels) orelse return false; - if (result.active_page != state.page) activatePage(core, pane, result.active_page, false); - return true; -} - -pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { - if (comptime !enabled) return; - if (!core.native_images) return stepPanePage(core, pane, direction); - const rows: u32 = @intCast(@max(1, config.wheel_rows)); - const pixels = scaledStep(core.cell_pixels.h, rows); - _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction); -} - -pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void { - if (comptime !enabled) return; - const state = pane.pdf orelse return; - if (!core.native_images or state.fit != .height) return; - const cols: u32 = @intCast(@max(1, config.wheel_cols)); - _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols)); -} - -/// Render every visible page attachment and the PDF-owned scrollbar. The -/// canonical text body remains the core's fallback when native pixels are -/// unavailable, so false means renderPane should continue normally. -pub fn draw( - core: *pardes.Pardes, - pane: *pardes.Pane, - rect: pardes.Rect, - pane_id: usize, - text_x: u16, - text_width: u16, -) bool { - if (comptime !enabled) return false; - if (!core.native_images or rect.h <= pardes.BOX_H) return false; - const state = &(pane.pdf orelse return false); - const view = paneViewport(core, pane) orelse return false; - const key = TintKey{ .mode = state.tint, .colors = tintColors(core) }; - const visible = renderFrame( - state, - core.pdf_gpa, - core.scratch.allocator(), - view, - pardes.pdf_raster_policy, - key, - highlightInput(core, pane_id, pane), - ); - var placed_any = false; - var page = visible.first; - const visible_end = visible.first + visible.len; - while (page < visible_end) : (page += 1) { - const placed = placedRaster(state, view, page) orelse continue; - if (!core.appendImagePlace(.{ - .pane = @intCast(pane_id), - .serial = pane.serial, - .native = .{ - .revision = placed.revision, - .page = @intCast(placed.page), - .fit = switch (placed.fit) { - .width => .width, - .height => .height, - }, - .pan_x = placed.pan_x, - .geometry = placed.geometry, - .pixel_offset_y = placed.pixel_offset_y, - }, - .x = text_x, - .y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H, - .w = text_width, - .h = rect.h - pardes.BOX_H, - .rgba = placed.rgba, - .iw = placed.width, - // The texture contains the retained band, not the full page. - .ih = placed.band_height, - })) break; - placed_any = true; - } - if (!placed_any) return false; - - // This frame has spent the motion used to choose its raster band. - state.scroll_travel = 0; - const body_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; - const chrome = core.chromeTheme(); - const theme = core.theme(); - const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; - core.surface.fill(rect.x, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); - core.surface.fill(rect.x + 1, body_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg }); - - const track_h: usize = rect.h - pardes.BOX_H; - const total = @max(@as(u64, 1), state.document_height); - const len = @max( - @as(usize, 1), - @as(usize, @intCast(@min( - @as(u64, track_h), - @as(u64, track_h) * view.pixel_h / total, - ))), - ); - const offset: u64 = @intFromFloat(@floor(state.document_scroll_y)); - const pos: usize = @intCast(@min( - @as(u64, track_h -| 1), - @as(u64, track_h) * offset / total, - )); - var y = pos; - while (y < track_h and y < pos + len) : (y += 1) - core.surface.fill( - rect.x, - body_y + @as(u16, @intCast(y)), - 1, - 1, - .{ .bg = .{ .rgb = chrome.scroll_thumb } }, - ); - return true; -} - -pub const SectionRows = if (enabled) struct { - pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.OutlineDestination { - return switch (destination) { - .internal => destination, - .external => |uri| if (safeHttpUri(uri)) destination else null, - .none => null, - }; - } - - fn safeHttpUri(uri: []const u8) bool { - // Effect.open_link is inline and cannot carry a larger URL. Omitting - // it here is preferable to rendering a row which can never act. - if (uri.len > 256) return false; - var has_scheme = false; - for (config.url_schemes) |scheme| { - if (std.mem.startsWith(u8, uri, scheme)) { - has_scheme = true; - break; - } - } - if (!has_scheme) return false; - for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false; - return true; - } - - /// A structural node goes to the first later DFS entry still below it - /// which carries a usable destination. Nodes with their own unusable URI - /// are not structural: omit them instead of silently changing their link. - pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.OutlineDestination { - if (ordinal >= entries.len) return null; - const entry = entries[ordinal]; - if (entry.destination != .none) return usableDestination(entry.destination); - var i = ordinal + 1; - while (i < entries.len and entries[i].depth > entry.depth) : (i += 1) - if (usableDestination(entries[i].destination)) |destination| return destination; - return null; - } - - /// Resolve every rendered row in one DFS pass. `resolve` above stays the - /// public single-ordinal policy used by Look; bulk materialisation avoids - /// rescanning the same descendant chain for every structural ancestor. - /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192 - /// outline-item limit) and stores source ordinals, so URI slices continue - /// to borrow from the document-owned outline instead of being copied. - pub fn resolveOrdinals( - gpa: std.mem.Allocator, - entries: []const pdf.OutlineEntry, - ) ![]usize { - const unresolved = std.math.maxInt(usize); - const ordinals = try gpa.alloc(usize, entries.len); - @memset(ordinals, unresolved); - - const Pending = struct { depth: u8, ordinal: usize }; - // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold - // more than 256 simultaneously unresolved ancestors, independent of - // the tighter limit enforced by the MuPDF bridge. - var pending: [256]Pending = undefined; - var pending_len: usize = 0; - for (entries, 0..) |entry, ordinal| { - while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth) - pending_len -= 1; - - if (usableDestination(entry.destination) != null) { - ordinals[ordinal] = ordinal; - for (pending[0..pending_len]) |ancestor| - ordinals[ancestor.ordinal] = ordinal; - pending_len = 0; - } else if (entry.destination == .none) { - pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal }; - pending_len += 1; - } - } - return ordinals; - } - - fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize { - const raw = title orelse { - if (out) |buf| @memcpy(buf[0..10], "[untitled]"); - return 10; - }; - var at: usize = 0; - var i: usize = 0; - var wrote = false; - var pending_space = false; - while (i < raw.len) { - const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch { - pending_space = wrote; - i += 1; - continue; - }; - if (i + n > raw.len) { - pending_space = wrote; - break; - } - const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch { - pending_space = wrote; - i += n; - continue; - }; - const whitespace_or_control = cp <= 0x20 or cp == 0x7f or - (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029; - if (whitespace_or_control) { - pending_space = wrote; - } else { - if (pending_space) { - if (out) |buf| buf[at] = ' '; - at += 1; - } - if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]); - at += n; - wrote = true; - pending_space = false; - } - i += n; - } - if (!wrote) { - if (out) |buf| @memcpy(buf[0..13], "[empty title]"); - return 13; - } - return at; - } - - pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 { - const target = std.fs.path.basename(path); - const ordinals = try resolveOrdinals(gpa, entries); - defer gpa.free(ordinals); - var total: usize = 0; - for (entries, 0..) |entry, ordinal| { - const resolved = ordinals[ordinal]; - if (resolved == std.math.maxInt(usize)) continue; - const destination = usableDestination(entries[resolved].destination) orelse unreachable; - total += switch (destination) { - .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), - .external => |uri| std.fmt.count("{s} ", .{uri}), - .none => unreachable, - }; - total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1; - } - const out = try gpa.alloc(u8, total); - errdefer gpa.free(out); - var at: usize = 0; - for (entries, 0..) |entry, ordinal| { - const resolved = ordinals[ordinal]; - if (resolved == std.math.maxInt(usize)) continue; - const destination = usableDestination(entries[resolved].destination) orelse unreachable; - const prefix = switch (destination) { - .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), - .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}), - .none => unreachable, - }; - at += prefix.len; - const indent = @as(usize, entry.depth) * 2; - @memset(out[at..][0..indent], ' '); - at += indent; - at += cleanTitle(out[at..], entry.title); - out[at] = '\n'; - at += 1; - } - return out; - } -} else struct {}; - -test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { - if (!enabled) return; - const entries = [_]pdf.OutlineEntry{ - .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } }, - .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } }, - .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } }, - .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } }, - .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } }, - }; - const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries); - defer std.testing.allocator.free(rows); - try std.testing.expectEqualStrings( - "manual.pdf:3:1 [untitled]\n" ++ - "manual.pdf:3:2 Child Title Café Next\n" ++ - "manual.pdf:1:3 [empty title]\n" ++ - "https://example.com/manual External\n" ++ - "https://example.com/deep Linked branch\n" ++ - "https://example.com/deep Deep link\n", - rows, - ); - try std.testing.expect(SectionRows.resolve(&entries, 4) == null); - try std.testing.expect(SectionRows.resolve(&entries, 5) == null); - const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); - defer std.testing.allocator.free(resolved); - for (entries, 0..) |_, ordinal| { - const single = SectionRows.resolve(&entries, ordinal); - if (resolved[ordinal] == std.math.maxInt(usize)) { - try std.testing.expect(single == null); - } else { - const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse - return error.MissingBulkPdfSectionDestination; - try std.testing.expect(single != null); - try std.testing.expect(std.meta.eql(single.?, bulk)); - } - } - - // Every allocation site in the ordinal table and output growth remains - // atomic: the testing allocator sees each induced failure cleaned up - // before the first index at which the whole render can succeed. - var rendered = false; - for (0..64) |fail_index| { - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ - .fail_index = fail_index, - }); - const failure_gpa = failing.allocator(); - const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| { - try std.testing.expectEqual(error.OutOfMemory, err); - continue; - }; - failure_gpa.free(attempt); - rendered = true; - break; - } - try std.testing.expect(rendered); -} - -test "bulk PDF section resolution matches single Look policy across DFS boundaries" { - if (!enabled) return; - const entries = [_]pdf.OutlineEntry{ - .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } }, - .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } }, - .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } }, - .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, - .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } }, - }; - const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); - defer std.testing.allocator.free(bulk); - const none = std.math.maxInt(usize); - try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk); - for (entries, 0..) |_, ordinal| { - const single = SectionRows.resolve(&entries, ordinal); - if (bulk[ordinal] == none) { - try std.testing.expect(single == null); - } else { - const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse - return error.MissingBoundaryPdfSectionDestination; - try std.testing.expect(single != null); - try std.testing.expect(std.meta.eql(single.?, destination)); - } - } -} - -test "PDF word probe owns quads and text without a selection object" { - if (comptime !enabled) return; - var document = try Document.open("docs/design.pdf"); - defer document.deinit(); - var found = try document.search(std.testing.allocator, 0, "Pardes"); - defer found.deinit(std.testing.allocator); - const quad = found.quads[0].quad; - const point = Point{ - .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, - .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, - }; - - // Exactly one shared probe exercises the primitive used by both UI paths; - // click-vs-hover equivalence belongs to production structure, not a second - // test that can only restate this MuPDF result. - var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse - return error.MissingPdfWordProbe; - defer probe.deinit(std.testing.allocator); - try std.testing.expectEqual(@as(usize, 0), probe.page); - try std.testing.expect(probe.quads.len > 0); - try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null); -} - -test "PDF point mapping restores band origin and fractional placement" { - if (comptime !enabled) return; - const point = pointAtGeometry( - .{ - .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 }, - .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 }, - }, - -0.5, - 200, - 200, - 80, - 120, - 34, - false, - ) orelse return error.MissingPdfMappedPoint; - // x: src 30; y: band 80 + local src 14. The returned coordinates address - // pixel centers, matching MuPDF's normalized page-space contract. - try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001); - try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001); -} diff --git a/src/pdf_pane_integration_test.zig b/src/pdf_pane_integration_test.zig deleted file mode 100644 index f0a8be8f..00000000 --- a/src/pdf_pane_integration_test.zig +++ /dev/null @@ -1,1819 +0,0 @@ -//! End-to-end PDF pane tests. Production state and behavior stay in pdf_pane.zig; -//! this module exercises their direct seam with Pardes layout, input, and output. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const pdf_pane = @import("pdf_pane.zig"); -const file_pane = @import("file_pane.zig"); -const look = @import("look.zig"); -const pdf_impl = pdf_pane.pdf; -const image = @import("image.zig"); -const config = @import("config.zig"); -const builtins = @import("builtins.zig"); -const animation = pardes.animation; -const dump = pardes.dump; - -const Pardes = pardes.Pardes; -const Builtin = builtins.registry.Builtin(); -const Key = pardes.Key; -const Event = pardes.Event; -const Mode = pardes.Mode; -const Surface = pardes.Surface; -const ImagePlace = pardes.ImagePlace; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; -const pdf_enabled = pdf_pane.enabled; -const platform = pardes.platform; -const themes = pardes.themes; -const pdf_raster_policy = pardes.pdf_raster_policy; -const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX; -const BOX_H = pardes.BOX_H; -const sel_slot = @intFromEnum(config.select_button); -const pane_tail = " " ++ config.pane_builtins_str; - -fn hasPdf(pane: *const pardes.Pane) bool { - return if (comptime pdf_enabled) pane.pdf != null else false; -} - -test "PDF feature gates keep argv and builtin behavior coherent" { - const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); - const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint"); - const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections"); - try std.testing.expectEqual(pdf_enabled, maybe_fit != null); - try std.testing.expectEqual(pdf_enabled, maybe_tint != null); - try std.testing.expectEqual(pdf_enabled, maybe_sections != null); - if (pdf_enabled) { - try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?); - try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?); - try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?); - } else if (platform != .web) { - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - try std.testing.expect(pane.file != null); - try std.testing.expect(pane.image == null); - try std.testing.expect(!hasPdf(pane)); - } -} - -test "PDF dump fallback remains a byte-preserving file" { - const gpa = std.testing.allocator; - const path = "/definitely/missing/pardes-dump-fallback.PDF"; - const source = "%PDF embedded fallback bytes\x00\xff"; - const encoded = try dump.encodeBytes(gpa, source); - defer gpa.free(encoded); - const ids = [_]usize{0}; - const columns = [_]dump.Column{.{ .panes = &ids }}; - const panes = [_]dump.Pane{.{ - .kind = .image, - .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del", - .body = "", - .scroll = 2, - .cols = 80, - .rows = 24, - .image = .{ .path = path, .bytes_b64 = encoded }, - }}; - const state = dump.State{ - .screen = .{ .cols = 80, .rows = 24 }, - .columns = &columns, - .panes = &panes, - }; - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); - - const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written()); - defer restored.deinit(); - const pane = restored.panes[0].?; - try std.testing.expect(pane.file != null); - try std.testing.expect(pane.image == null); - try std.testing.expect(!hasPdf(pane)); - try std.testing.expectEqualStrings(path, pane.file.?.path); - try std.testing.expectEqualSlices(u8, source, pane.file.?.content); - try std.testing.expect(pane.tag_init); - try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]); -} - -test "PdfSections Look follows the exact owning PDF, not an equal path" { - if (!pdf_enabled or platform == .web) return; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const fixture = try pdf_impl.makeOutlineTestPdf(gpa); - defer gpa.free(fixture); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); - - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - const first = p.panes[0].?; - // Bare normal-mode `f` on a PDF dispatches the PdfSections builtin. - p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); - const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput; - const first_output = p.panes[first_output_id].?; - try std.testing.expect(pdf_pane.isSectionsOutput(first_output)); - try std.testing.expect(first_output.file.?.content.len > 0); - - // A clean result is re-armed in place without rebuilding it. - const first_revision = first_output.file.?.revision; - pdf_pane.openSections(p, 0); - try std.testing.expectEqual(first_output_id, first.search_pane.?); - try std.testing.expectEqual(first_revision, first_output.file.?.revision); - - const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; - const duplicate = try pdf_pane.openPane(p, duplicate_id, path, 0); - p.placeDoc(0, duplicate_id, duplicate); - p.computeGeom(); - pdf_pane.openSections(p, duplicate_id); - const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; - - const first_row = std.mem.sliceTo(first_output.file.?.content, '\n'); - const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len]; - p.lookAt(first_output_id, target); - try std.testing.expectEqual(@as(usize, 0), p.active); - p.lookAt(duplicate_output_id, target); - try std.testing.expectEqual(duplicate_id, p.active); - - // Once the original document is gone, its output cannot reinterpret an - // old ordinal against the still-open equal-path duplicate. - try std.testing.expect(pardes.pdf_test.runBuiltin(p, "Del", 0, "", null)); - p.active = first_output_id; - p.lookAt(first_output_id, target); - try std.testing.expectEqual(first_output_id, p.active); -} - -test "PdfSections caches an empty outline output" { - if (!pdf_enabled or platform == .web) return; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa); - defer gpa.free(fixture); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path}); - const p = try Pardes.init(gpa, .{ .file = path }); - defer p.deinit(); - pdf_pane.openSections(p, 0); - const pane = p.panes[0].?; - const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections; - try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len); - const serial = p.panes[output_id].?.serial; - pdf_pane.openSections(p, 0); - try std.testing.expectEqual(output_id, pane.search_pane.?); - try std.testing.expectEqual(serial, p.panes[output_id].?.serial); -} - -test "SDL PDF raster policy is materially denser than Kitty" { - try std.testing.expect( - pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2, - ); - try std.testing.expect( - pardes.sdl_pdf_raster_policy.max_dimension > - pardes.kitty_pdf_raster_policy.max_dimension * 3, - ); - try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport); - try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport); -} - -test "watched PDF reload replaces MuPDF state and preserves the reading view" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const original = try pdf_impl.makeOutlineTestPdf(gpa); - defer gpa.free(original); - const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); - defer gpa.free(replacement); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint( - &path_buf, - ".zig-cache/tmp/{s}/live.pdf", - .{tmp.sub_path}, - ); - - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - var watched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |watch| if (watch.pane == 0 and watch.on) { - watched = true; - }, - else => {}, - }; - try std.testing.expect(watched); - - const pane = p.panes[0].?; - const state = &pane.pdf.?; - try std.testing.expectEqual(@as(usize, 3), state.page_count); - pdf_pane.openSections(p, 0); - const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput; - const sections = p.panes[sections_id].?; - try std.testing.expect(sections.file.?.content.len > 0); - - p.native_images = true; - state.fit = .height; - state.tint = .full; - state.pan_x = 1234; - state.pan_y = 4321; - try state.setSearchQuery(p.pdf_gpa, "needle"); - pdf_pane.setPage(p, pane, 2); - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - state.text_scroll = 7; - state.search_hit = 4; - state.search_reveal_pending = false; - const reveal_viewport_w = state.reveal_viewport_w; - const reveal_viewport_h = state.reveal_viewport_h; - const reveal_fit = state.reveal_fit; - const reveal_viewport_valid = state.reveal_viewport_valid; - const old_revision = pdf_pane.rasterForPage(state, 2).?.revision; - const old_revision_counter = state.next_raster_revision; - const anchor_fraction: f64 = 0.375; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2])); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - pane.cur_row = 9; - pane.cur_col = 8; - - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - - try std.testing.expectEqual(@as(usize, 1), state.page_count); - try std.testing.expectEqual(@as(usize, 0), state.page); - try std.testing.expectEqual(PdfFitMode.height, state.fit); - try std.testing.expectEqual(PdfTintMode.full, state.tint); - try std.testing.expectEqual(@as(u16, 1234), state.pan_x); - try std.testing.expectEqual(@as(u16, 4321), state.pan_y); - try std.testing.expectEqual(@as(usize, 7), state.text_scroll); - try std.testing.expect(state.text_scroll_clamp_pending); - try std.testing.expectEqualStrings("needle", state.search_query); - try std.testing.expectEqual(@as(usize, 4), state.search_hit); - try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w); - try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h); - try std.testing.expectEqual(reveal_fit, state.reveal_fit); - try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid); - try std.testing.expectEqual(old_revision_counter, state.next_raster_revision); - try std.testing.expectEqual(@as(usize, 0), state.rasters_len); - try std.testing.expect(state.layout_anchor_pending); - try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page); - try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001); - try std.testing.expectEqual(@as(i32, 0), pane.cur_row); - try std.testing.expectEqual(@as(i32, 0), pane.cur_col); - _ = state.ensureText(p.pdf_gpa); - const text_lines = std.mem.count(u8, state.text, "\n") + 1; - try std.testing.expect(state.text_scroll < text_lines); - try std.testing.expect(!state.text_scroll_clamp_pending); - - // A clean generated outline is derived data: it refreshes in place and - // remains the remembered output. This replacement deliberately has none. - try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len); - try std.testing.expectEqual(sections_id, state.sections_output.?.pane); - try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision); - - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const fresh_raster = pdf_pane.rasterForPage(state, 0).?; - try std.testing.expect(fresh_raster.revision != old_revision); - try std.testing.expect(fresh_raster.revision > old_revision_counter); - const expected_scroll = anchor_fraction * - @as(f64, @floatFromInt(state.page_heights[0])); - const viewport = pdf_pane.paneViewport(p, pane).?; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001); - - // Reopen is transactional: malformed replacement bytes leave the last - // good MuPDF handle and every durable setting untouched. - const good_handle = state.document.handle; - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" }); - try std.testing.expect(!p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(good_handle, state.document.handle); - try std.testing.expectEqual(@as(usize, 1), state.page_count); - try std.testing.expectEqualStrings("needle", state.search_query); - try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null); - - // A user edit breaks the generated-revision token. A later valid reload - // updates the document but leaves those user-owned output bytes alone. - file_pane.setContent(p, §ions.file.?, try gpa.dupe(u8, "edited sections\n")); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(@as(usize, 3), state.page_count); - try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content); - - // The watch follows the PDF payload's lifetime just like a text file's; - // emit the stop while the slot still identifies the disappearing pane. - p.deinitPane(pane); - p.panes[0] = null; - var unwatched = false; - while (p.nextEffect()) |effect| switch (effect) { - .watch => |watch| if (watch.pane == 0 and !watch.on) { - unwatched = true; - }, - else => {}, - }; - try std.testing.expect(unwatched); -} - -test "PDF reload does not re-center an already revealed matching search" { - if (!pdf_enabled or platform == .web) return; - const gpa = std.testing.allocator; - const fixture = try look.readFile(gpa, "docs/design.pdf"); - defer gpa.free(fixture); - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path}); - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - p.native_images = true; - const pane = p.panes[0].?; - const state = &pane.pdf.?; - try state.setSearchQuery(p.pdf_gpa, "Pardes"); - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - try std.testing.expect(state.search_results != null); - try std.testing.expect(state.search_results.?.hit_count > 0); - try std.testing.expect(state.reveal_viewport_valid); - - // Reading moved on after the original reveal. A reload retains the query - // and rebuilds its quads, but it must not mistake that for a new request to - // jump back to the hit. - const anchor_page = state.page; - const anchor_fraction: f64 = 0.82; - state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page])); - state.scroll_to_page_pending = false; - state.layout_anchor_pending = false; - state.search_reveal_pending = false; - const reveal_w = state.reveal_viewport_w; - const reveal_h = state.reveal_viewport_h; - const reveal_fit = state.reveal_fit; - - // Same semantic document on a fresh generation keeps the expected anchor - // easy to state while still rebuilding every MuPDF-owned search object. - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); - try std.testing.expect(p.reloadWatchedFile(0, &.{})); - try std.testing.expectEqual(reveal_w, state.reveal_viewport_w); - try std.testing.expectEqual(reveal_h, state.reveal_viewport_h); - try std.testing.expectEqual(reveal_fit, state.reveal_fit); - try std.testing.expect(!state.search_reveal_pending); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - - const page = @min(anchor_page, state.page_count - 1); - const anchored = @as(f64, @floatFromInt(state.page_starts[page])) + - anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); - const viewport = pdf_pane.paneViewport(p, pane).?; - const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); - try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001); -} - -test "MuPDF pane renders, navigates, searches, and round-trips its page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - try std.testing.expect(hasPdf(pane)); - try std.testing.expect(pane.pdf.?.page_count > 1); - try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); - try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); - try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); - const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); - try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); - try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); - - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const first = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 1), first.nimages); - const first_place = first.images[0].?; - try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); - try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); - const request = pdf_pane.renderRequest( - pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport, - pdf_raster_policy, - ); - try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); - try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); - try std.testing.expectEqual( - request, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request, - ); - try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); - if (pdf_raster_policy.match_viewport) { - const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport; - try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); - try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); - try std.testing.expect(first_place.iw >= viewport.pixel_w or - @max(first_place.iw, first_place.ih) == request.max_dimension); - try std.testing.expect(first_place.ih >= viewport.pixel_h or - @max(first_place.iw, first_place.ih) == request.max_dimension); - } else { - try std.testing.expectEqual(@as(u32, 0), request.minimum_width); - try std.testing.expectEqual(@as(u32, 0), request.minimum_height); - } - - // SDL's raw dy path retains fractions in the placement itself; it does - // not leave native pixels fixed while only the underlying cells slide. - p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } }); - try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y); - _ = frame.reset(.retain_capacity); - const fractional = try p.render(frame.allocator()); - try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision); - try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y); - pane.pdf.?.document_scroll_y = 0; - - // The default reading view moves one exact display-cell distance without - // replacing page pixels. Document placement, not texture identity, moves. - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y); - try std.testing.expectEqual( - first_place.native.revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - _ = frame.reset(.retain_capacity); - const panned = try p.render(frame.allocator()); - try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); - try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y); - - const row_scroll = pane.pdf.?.document_scroll_y; - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y); - - // Counts survive a shared multi-key prefix. An invalid continuation is - // consumed and clears both prefix and count before the following action. - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '4' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = '?' } }); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - try std.testing.expectEqual(@as(u32, 0), pane.count); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); - - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - const half_scroll = pane.pdf.?.document_scroll_y; - try std.testing.expect(half_scroll > row_scroll); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - const counted_half = pane.pdf.?.document_scroll_y; - try std.testing.expectEqual(half_scroll * 2, counted_half); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); - try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4; - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll); - pane.pdf.?.document_scroll_y = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - const key_viewport = pdf_pane.paneViewport(p, pane).?; - const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h)); - const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll); - try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); - try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y); - try std.testing.expectEqual( - first_place.native.revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - - // At a page boundary both page rasters coexist, the gap remains uncovered, - // and a row step crosses it without snapping either page to an edge. - // - // This lands by ASSIGNMENT, which is a jump and not a fling — and the - // travel counter has to say so: the keys above scrolled two screenfuls - // without any frame in between to spend that distance, which no shell does - // (every wheel batch is followed by a draw). Left unspent it would make the - // frame below the first frame of a fling and hand it bands. - const viewport = pdf_pane.paneViewport(p, pane).?; - pane.pdf.?.scroll_travel = 0; - pane.pdf.?.document_scroll_y = @floatFromInt( - pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, - ); - _ = frame.reset(.retain_capacity); - const boundary = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 2), boundary.nimages); - try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page); - try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page); - const first_bottom = @as(f32, @floatFromInt( - boundary.images[0].?.native.geometry.?.dst.y + - boundary.images[0].?.native.geometry.?.dst.h, - )) + boundary.images[0].?.native.pixel_offset_y; - const second_top = @as(f32, @floatFromInt( - boundary.images[1].?.native.geometry.?.dst.y, - )) + boundary.images[1].?.native.pixel_offset_y; - const visible_gap = second_top - first_bottom; - try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX)); - try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1)); - const before_boundary_scroll = pane.pdf.?.document_scroll_y; - const page0_revision = boundary.images[0].?.native.revision; - const page1_revision = boundary.images[1].?.native.revision; - - // A scroll can activate the already-cached neighbor before the shell's - // next draw. Every consumer resolves the active page's resident raster, - // so a queued click cannot accidentally use page zero's dimensions. - pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5; - try std.testing.expectEqual( - page0_revision, - pdf_pane.rasterForPage(&pane.pdf.?, 0).?.revision, - ); - try std.testing.expect(pdf_pane.scrollPane(p, pane, 1)); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - try std.testing.expectEqual( - page1_revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - pdf_pane.activatePage(p, pane, 0, false); - pane.pdf.?.document_scroll_y = before_boundary_scroll; - - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y); - _ = frame.reset(.retain_capacity); - const second = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 2), second.nimages); - try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision); - try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); - - // Once page zero is wholly outside the viewport, both its owned RGBA and - // backend placement disappear; returning later renders a new raster. What - // does NOT go back is the memory: the departing page's bytes are parked in - // the relay and the arriving page of the same size takes them, so a fling - // never asks the allocator (or the kernel's fault handler) for megabytes it - // just gave up. - pdf_pane.setPage(p, pane, 1); - _ = frame.reset(.retain_capacity); - const away = try p.render(frame.allocator()); - try std.testing.expect(away.nimages > 0); - for (away.images[0..away.nimages]) |maybe| if (maybe) |place| - try std.testing.expect(place.native.page != 0); - try std.testing.expect(pdf_pane.rasterForPage(&pane.pdf.?, 0) == null); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); - const retired = pane.pdf.?.spare[0]; - pdf_pane.setPage(p, pane, 0); - _ = frame.reset(.retain_capacity); - const returned = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page); - try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr); - try std.testing.expect(returned.images[0].?.native.revision != page0_revision); - - // PdfFit exists as a real builtin in this build. It resets placement but - // preserves the current page pixels; fit-height j/k remains continuous in - // the same document-pixel coordinate space. - const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse - return error.MissingPdfFitBuiltin; - const revision_before_toggle = pdf_pane.rasterForPage( - &pane.pdf.?, - pane.pdf.?.page, - ).?.revision; - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(fit_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); - try std.testing.expectEqual( - revision_before_toggle, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - const height_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); - try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); - for (p.panes) |slot| { - const other = slot orelse continue; - if (hasPdf(other)) continue; - pdf_pane.toggleFit(other); // pane-scoped and deliberately inert here - try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); - break; - } - - // A fit-height landscape page exposes horizontal overflow to a horizontal - // wheel without rerasterizing. Use synthetic dimensions only for the - // geometry check; no frame is drawn while they differ from the buffer. - const active_raster = pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?; - const saved_iw = active_raster.iw; - const saved_ih = active_raster.ih; - active_raster.iw = 2000; - active_raster.ih = 500; - pdf_pane.horizontalWheel(p, pane, 1); - try std.testing.expect(pane.pdf.?.pan_x > 0); - try std.testing.expectEqual(revision_before_toggle, active_raster.revision); - pane.pdf.?.pan_x = 0; - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'l' } }); - try std.testing.expect(pane.pdf.?.pan_x > 0); - p.update(.{ .key = .{ .cp = '$' } }); - try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'h' } }); - try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16)); - p.update(.{ .key = .{ .cp = '0' } }); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'l' } }); - try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'h' } }); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); - active_raster.iw = saved_iw; - active_raster.ih = saved_ih; - pane.pdf.?.pan_x = 0; - - const before_height_scroll = pane.pdf.?.document_scroll_y; - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); - - const revision_before_search = active_raster.revision; - try p.runSearch(0, "Pardes", .text, .top); - try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); - try std.testing.expectEqual(revision_before_search, active_raster.revision); - const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; - const results = p.panes[results_id].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); - // n SELECTS the first result row and opens nothing: the walk's only list - // here is the unlooked +Search buffer, so focus lands THERE. Enter is what - // jumps, and the document query survives both. - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(results_id, p.active); - const results_pane = p.panes[results_id].?; - try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit); - try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row); - try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col); - p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); - - // Search state is owned and untruncated, and changing pages invalidates - // only page-local state while retaining the document query. - const long_query = "a query deliberately longer than any tag display budget: " ++ - "012345678901234567890123456789012345678901234567890123456789" ++ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query); - try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); - const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; - pdf_pane.setPage(p, pane, next_page); - try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); - - // PDF dumps intentionally retain the existing image-compatible schema: - // page/path and the exact editable tail are restored, while pane-local - // tint starts from the fresh-PDF default rather than being serialized. - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint); - pane.pdf.?.fit = .height; - const custom_tail = " Keep Del"; - @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail); - pane.tag_tail_len = custom_tail.len; - pane.tag_init = true; - try p.dumpState(); - const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); - defer restored.deinit(); - try std.testing.expect(hasPdf(restored.panes[0].?)); - try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); - try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit); - try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); - const restored_pane = restored.panes[0].?; - try std.testing.expect(restored_pane.tag_init); - try std.testing.expectEqualStrings( - custom_tail, - restored_pane.tag_tail[0..restored_pane.tag_tail_len], - ); -} - -test "a fling's banded pages show the reader exactly what whole pages would" { - if (!pdf_enabled or platform == .web) return; - - // The contract fast scrolling is allowed to change: HOW pixels are carried - // (a strip of a page instead of the page) but never WHICH pixels arrive. So - // the same frame is drawn twice — once at fling speed, once at reading - // speed — and every pixel inside every source rectangle must match, along - // with where on screen it goes. - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 }); - defer p.deinit(); - p.native_images = true; - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - - const viewport = pdf_pane.paneViewport(p, pane) orelse return error.MissingPdfViewport; - // Land mid-page-boundary so the frame carries TWO pages, each showing a - // fraction of itself — the shape a fling actually produces. - const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); - const Shot = struct { - page: u32, - dst: image.PixelRect, - pixels: []u8, - }; - var shots: [8]Shot = undefined; - var shots_len: usize = 0; - defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); - - // A fling: one frame's worth of wheel travel carrying the viewport more - // than a screenful, delivered through the real scroll path so the distance - // is counted the way a wheel batch counts it. - pv.document_scroll_y = 0; - pv.scroll_travel = 0; - try std.testing.expect(pdf_pane.scrollPane(p, pane, landing)); - _ = frame.reset(.retain_capacity); - const flung = try p.render(frame.allocator()); - try std.testing.expect(flung.nimages >= 2); - var banded = false; - for (flung.images[0..flung.nimages]) |maybe| { - const place = maybe orelse continue; - const geometry = place.native.geometry orelse return error.MissingPdfGeometry; - const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse - return error.MissingPdfRaster; - if (raster.band_h < raster.ih) banded = true; - try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); - shots[shots_len] = .{ - .page = place.native.page, - .dst = geometry.dst, - .pixels = try copySourceRect(gpa, place, geometry.src), - }; - shots_len += 1; - } - // ...and it really did band, or the comparison below is two identical - // whole-page renders agreeing with each other. - try std.testing.expect(banded); - // ...at reading speed: no travel at all since the frame above, so every - // page is rasterized whole again, and that is the picture the banded frame - // has to have matched. - _ = frame.reset(.retain_capacity); - const rested = try p.render(frame.allocator()); - try std.testing.expectEqual(shots_len, rested.nimages); - for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { - const place = maybe orelse return error.MissingPdfPlacement; - const geometry = place.native.geometry orelse return error.MissingPdfGeometry; - const raster = pdf_pane.rasterForPage(pv, place.native.page) orelse - return error.MissingPdfRaster; - try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest - try std.testing.expectEqual(shot.page, place.native.page); - try std.testing.expectEqual(shot.dst.x, geometry.dst.x); - try std.testing.expectEqual(shot.dst.y, geometry.dst.y); - try std.testing.expectEqual(shot.dst.w, geometry.dst.w); - try std.testing.expectEqual(shot.dst.h, geometry.dst.h); - const whole = try copySourceRect(gpa, place, geometry.src); - defer gpa.free(whole); - try std.testing.expectEqualSlices(u8, shot.pixels, whole); - } -} - -/// The pixels a backend samples out of one placement: the source rectangle, -/// row by row, at the texture's own stride. Test-only, and the one operation -/// that makes "same picture" mean something when the textures differ in shape. -fn copySourceRect( - gpa: std.mem.Allocator, - place: ImagePlace, - src: image.PixelRect, -) ![]u8 { - const stride = place.iw * 4; - const row_len = @as(usize, src.w) * 4; - const out = try gpa.alloc(u8, row_len * src.h); - errdefer gpa.free(out); - var row: usize = 0; - while (row < src.h) : (row += 1) { - const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; - @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); - } - return out; -} - -test "PDF normal adapter consumes unsupported actions and navigates page fallback" { - if (!pdf_enabled or platform == .web) return; - - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - try std.testing.expect(pv.page_count > 3); - p.native_images = false; - - // Every vertical vocabulary falls back to counted page changes when the - // shell cannot place native pixels. - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - - // Prefix actions and the counted text goto-line action map to pages. - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'e' } }); - try std.testing.expectEqual(pv.page_count - 1, pv.page); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - try std.testing.expectEqual(@as(usize, 0), pv.page); - p.update(.{ .key = .{ .cp = '3' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - p.update(.{ .key = .{ .cp = 'g' } }); - try std.testing.expectEqual(@as(usize, 2), pv.page); - p.update(.{ .key = .{ .cp = '2' } }); - p.update(.{ .key = .{ .cp = 'G' } }); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - // Editing/selection actions are consumed no-ops: parser state clears, - // placeholder cells never acquire a range, and PDF state stays intact. - const page_before_noop = pv.page; - const revision_before_noop = pv.next_raster_revision; - p.update(.{ .key = .{ .cp = '4' } }); - p.update(.{ .key = .{ .cp = 'd' } }); - p.update(.{ .key = .{ .cp = 'v' } }); - p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expectEqual(page_before_noop, pv.page); - try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision); - try std.testing.expectEqual(@as(u32, 0), pane.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0); - try std.testing.expect(!pane.tag_edit); - - // Cross-pane BODY-NORMAL actions keep their established shared paths. - p.update(.{ .key = .{ .cp = ':' } }); - try std.testing.expect(pane.tag_edit); - try std.testing.expectEqual(Mode.normal, pane.mode); - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit); - p.update(.{ .key = .{ .cp = ' ' } }); - try std.testing.expect(p.leader_on); - p.update(.{ .key = .{ .cp = '!' } }); - try std.testing.expect(!p.leader_on); - p.update(.{ .key = .{ .cp = '/' } }); - try std.testing.expect(pane.tag_edit); - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit); -} - -test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 28, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - - // A second pane, then focus it and come back: `Last` has somewhere to go - // and the PDF is where the keys land. - pdf_pane.openSections(p, 0); - p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 1), p.active); - p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - - // Everything a reader can see over the page: a search overlay and a live - // word selection. - try pv.setSearchQuery(p.pdf_gpa, "Pardes"); - var found = try pv.document.search(gpa, pv.page, "Pardes"); - defer found.deinit(gpa); - try std.testing.expect(found.quads.len > 0); - const quad = found.quads[0].quad; - try std.testing.expectEqual( - pdf_pane.SelectionUpdate.changed, - pv.setSelection(p.pdf_gpa, quad.ul, quad.lr, false), - ); - p.update(.{ .key = .{ .cp = '3' } }); - const page_before = pv.page; - const scroll_before = pv.document_scroll_y; - - // Escape drops the overlay and the selection, keeps the reading position, - // and does NOT hand the keyboard to another pane. - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(pv.selection == null and pv.selection_text.len == 0); - try std.testing.expectEqual(@as(usize, 0), pv.search_query.len); - try std.testing.expect(pv.search_results == null); - try std.testing.expect(!pv.highlights_dirty and !pv.search_reveal_pending); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); - try std.testing.expectEqual(@as(u32, 0), pane.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - // A second Escape on a bare document is inert rather than an exit. - p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(usize, 0), p.active); - - // Shift-Escape is the way out, and it leaves the document as it found it. - p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); - try std.testing.expectEqual(@as(usize, 1), p.active); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); -} - -test "PDF continuous strip renders every intersecting short page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - try std.testing.expect(pv.page_count > 3); - p.native_images = true; - - // A legal wide MediaBox can make more than three pages intersect one - // viewport. Seed tiny matching rasters so this tests transport/cache - // cardinality without spending the unit test rendering fake page sizes. - for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 }; - pv.layout_valid = false; - pv.scroll_to_page_pending = true; - const viewport = pdf_pane.ensurePaneLayout(p, pane).?; - const visible = pdf_pane.visiblePages(pv, viewport); - try std.testing.expectEqual(pv.page_count, visible.len); - const request = pdf_pane.renderRequest(viewport, pdf_raster_policy); - try std.testing.expect(pv.page_count <= pv.rasters.len); - for (0..pv.page_count) |page| { - const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); - @memset(rgba, @intCast(page)); - pv.rasters[pv.rasters_len] = .{ - .valid = true, - .page = page, - .rgba = rgba, - .iw = viewport.pixel_w, - .ih = 1, - .request = request, - .request_valid = true, - .tried = true, - .tint_key = .{ .mode = pv.tint, .colors = pdf_pane.tintColors(p) }, - .revision = @intCast(page + 1), - }; - pv.rasters_len += 1; - } - - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const surface = try p.render(frame.allocator()); - try std.testing.expectEqual(pv.page_count, surface.nimages); - try std.testing.expectEqual(pv.page_count, pv.rasters_len); - for (surface.images[0..surface.nimages], 0..) |maybe, page| { - const place = maybe orelse return error.MissingShortPdfPage; - try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); - try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h); - } -} - -test "PdfTint cycles pane-local state and exposes it in the live PDF tag" { - if (!pdf_enabled or platform == .web) return; - - const p = try Pardes.init(std.testing.allocator, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse - return error.MissingPdfTintBuiltin; - - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - const initial_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf( - u8, - initial_tag, - "width PdfFit filtered PdfTint", - ) != null); - - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - const full_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null); - - // Running the same pane-scoped word in a terminal cannot mutate the PDF - // next to it. A FILE boot is one pane now, so that terminal is asked for - // here rather than inherited from init. - _ = try p.newShell(1, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did - try std.testing.expect(!hasPdf(p.panes[1].?)); - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 1, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - const disabled_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null); - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - try std.testing.expectEqual(PdfFitMode.width, pv.fit); - - // `dark` intentionally leaves page bg/fg null. PDF tint resolves those - // deterministically to its chrome colors rather than host defaults. - const dark_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "dark")) break i; - } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); - const colors = pdf_pane.tintColors(p); - try std.testing.expectEqual(p.theme().tag_bg, colors.background); - try std.testing.expectEqual(p.theme().tag_fg, colors.foreground); -} - -test "PDF tint and tinted theme changes rebuild every visible raster only" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - _ = try p.render(frame.allocator()); - const viewport = pdf_pane.paneViewport(p, pane).?; - pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2); - _ = frame.reset(.retain_capacity); - const default_surface = try p.render(frame.allocator()); - try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); - try std.testing.expectEqual(@as(usize, 2), default_surface.nimages); - try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); - - const Snapshot = struct { - page: u32, - revision: u32, - geometry: image.NativeGeometry, - pixel_offset_y: f32, - iw: usize, - ih: usize, - checksum: u64, - }; - const Capture = struct { - fn get(surface: *const Surface) ![2]Snapshot { - if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount; - var out: [2]Snapshot = undefined; - for (&out, 0..) |*snapshot, i| { - const place = surface.images[i] orelse return error.MissingVisiblePdf; - snapshot.* = .{ - .page = place.native.page, - .revision = place.native.revision, - .geometry = place.native.geometry orelse return error.MissingPdfGeometry, - .pixel_offset_y = place.native.pixel_offset_y, - .iw = place.iw, - .ih = place.ih, - .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba), - }; - } - return out; - } - - fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void { - for (before, after) |old, new| { - try std.testing.expectEqual(old.page, new.page); - try std.testing.expectEqual(old.geometry, new.geometry); - try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y); - try std.testing.expectEqual(old.iw, new.iw); - try std.testing.expectEqual(old.ih, new.ih); - } - } - }; - const default_filtered = try Capture.get(default_surface); - for (pv.rasters[0..pv.rasters_len]) |raster| - try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode); - - const dark_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "dark")) break i; - } else return error.MissingDarkTheme; - p.setThemeIndex(dark_index); - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); - _ = frame.reset(.retain_capacity); - const themed_surface = try p.render(frame.allocator()); - const themed = try Capture.get(themed_surface); - try Capture.expectGeometry(default_filtered, themed); - for (default_filtered, themed) |old, new| { - try std.testing.expect(new.revision > old.revision); - try std.testing.expect(new.checksum != old.checksum); - } - - // Chrome animation reads a separate palette. Its ticks must never disturb - // the target-theme tint key or ask MuPDF for the same pixels again. - try std.testing.expect(p.animationActive()); - for (0..animation.transition_steps) |_| { - p.update(.tick); - _ = frame.reset(.retain_capacity); - const tick_surface = try p.render(frame.allocator()); - const ticked = try Capture.get(tick_surface); - try Capture.expectGeometry(themed, ticked); - for (themed, ticked) |once, after_tick| { - try std.testing.expectEqual(once.revision, after_tick.revision); - try std.testing.expectEqual(once.checksum, after_tick.checksum); - } - } - try std.testing.expect(!p.animationActive()); - - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - _ = frame.reset(.retain_capacity); - const full_surface = try p.render(frame.allocator()); - const full = try Capture.get(full_surface); - try Capture.expectGeometry(themed, full); - for (themed, full) |old, new| { - try std.testing.expect(new.revision > old.revision); - } - for (pv.rasters[0..pv.rasters_len]) |raster| - try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode); - - pdf_pane.toggleTint(pane); - try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - _ = frame.reset(.retain_capacity); - const disabled_surface = try p.render(frame.allocator()); - const disabled = try Capture.get(disabled_surface); - try Capture.expectGeometry(full, disabled); - for (full, disabled) |old, source| { - try std.testing.expect(source.revision > old.revision); - try std.testing.expect(source.checksum != old.checksum); - } - - const acme_index = for (themes, 0..) |th, i| { - if (std.mem.eql(u8, th.name, "acme")) break i; - } else return error.MissingAcmeTheme; - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); - p.setThemeIndex(acme_index); - // Disabled keys ignore theme colors, so neither explicit invalidation nor - // the per-raster key comparison asks MuPDF for replacement pixels. - for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); - _ = frame.reset(.retain_capacity); - const unchanged_surface = try p.render(frame.allocator()); - const unchanged = try Capture.get(unchanged_surface); - try Capture.expectGeometry(disabled, unchanged); - for (disabled, unchanged) |old, new| { - try std.testing.expectEqual(old.revision, new.revision); - try std.testing.expectEqual(old.checksum, new.checksum); - } - try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); -} - -test "PDF resize preserves page-relative document position" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - pdf_pane.setPage(p, pane, 1); - _ = try p.render(frame.allocator()); - const fraction: f64 = 0.375; - pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + - fraction * @as(f64, @floatFromInt(pv.page_heights[1])); - p.update(.{ .resize = .{ - .cols = 100, - .rows = 24, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expect(!pv.layout_valid); - try std.testing.expect(pv.layout_anchor_pending); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const resized_fraction = (pv.document_scroll_y - - @as(f64, @floatFromInt(pv.page_starts[1]))) / - @as(f64, @floatFromInt(pv.page_heights[1])); - try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - const held = pv.document_scroll_y; - p.update(.{ .resize = .{ - .cols = 100, - .rows = 24, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expectEqual(held, pv.document_scroll_y); - try std.testing.expect(pv.layout_valid); -} - -test "PDF pane geometry change preserves its page-relative position" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - // A FILE boot is one pane now and a lone column always fills the window, - // so the divider drag below needs a second column to take the width from. - _ = try p.newShell(1, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.sync(p); // rects for the new column, exactly as the two-pane boot did - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - pdf_pane.setPage(p, pane, 1); - _ = try p.render(frame.allocator()); - const fraction: f64 = 0.625; - pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + - fraction * @as(f64, @floatFromInt(pv.page_heights[1])); - const old_width = pv.layout_viewport_w; - - // A divider/split changes rects through computeGeom without emitting a - // shell resize. pdf_pane.ensurePaneLayout anchors against the old layout - // before rebuilding it for this wider pane. - const sibling_weight = p.col_weight[1]; - p.col_weight[0] = sibling_weight * 6; - p.computeGeom(); - try std.testing.expect(pdf_pane.paneViewport(p, pane).?.pixel_w != old_width); - try std.testing.expect(pv.layout_valid); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - const changed_fraction = (pv.document_scroll_y - - @as(f64, @floatFromInt(pv.page_starts[1]))) / - @as(f64, @floatFromInt(pv.page_heights[1])); - try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001); - try std.testing.expectEqual(@as(usize, 1), pv.page); - - // An explicit pending reveal wins over an implicit layout anchor. - pv.page = 2; - pv.scroll_to_page_pending = true; - p.col_weight[0] = sibling_weight * 2; - p.computeGeom(); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - try std.testing.expectEqual( - @as(f64, @floatFromInt(pv.page_starts[2])), - pv.document_scroll_y, - ); -} - -test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 120, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - const pv = &pane.pdf.?; - p.native_images = true; - - var page_hits = try pv.document.search(gpa, 0, "Pardes"); - defer page_hits.deinit(gpa); - try std.testing.expect(page_hits.hit_count >= 3); - - try p.runSearch(0, "Pardes", .text, .top); - const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; - const rows = p.panes[results_id].?.file.?.content; - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); - try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); - - // Exaggerate the cell aspect only to make the three fixture hits occupy - // distinct fit-width crops. The search/reveal math must use the same - // reported metrics as placement, whatever a backend reports. - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = .{ .w = 16, .h = 4 }, - } }); - - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 0), pv.search_hit); - const first = try p.render(frame.allocator()); - const revision = first.images[0].?.native.revision; - const raster = pdf_pane.rasterForPage(pv, pv.page).?; - const first_scroll = pv.document_scroll_y; - const first_results = pv.search_results orelse return error.MissingPdfPageSearch; - const first_quad = for (first_results.quads) |item| { - if (item.hit == 0) break item.quad; - } else return error.MissingFirstPdfHit; - const first_y = pdf_pane.normalizedPixel( - (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, - raster.ih, - ); - const first_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; - try std.testing.expect(first_y >= first_geometry.src.y and - first_y < first_geometry.src.y + first_geometry.src.h); - - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 1), pv.search_hit); - _ = frame.reset(.retain_capacity); - const second = try p.render(frame.allocator()); - try std.testing.expectEqual(revision, second.images[0].?.native.revision); - - try std.testing.expect(pardes.pdf_test.searchStep(p, 0, 1)); - try std.testing.expectEqual(@as(usize, 2), pv.search_hit); - _ = frame.reset(.retain_capacity); - const third = try p.render(frame.allocator()); - try std.testing.expectEqual(revision, third.images[0].?.native.revision); - try std.testing.expect(pv.document_scroll_y != first_scroll); - const third_results = pv.search_results orelse return error.MissingPdfPageSearch; - const third_quad = for (third_results.quads) |item| { - if (item.hit == 2) break item.quad; - } else return error.MissingThirdPdfHit; - const third_y = pdf_pane.normalizedPixel( - (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, - raster.ih, - ); - const third_geometry = pdf_pane.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; - try std.testing.expect(third_y >= third_geometry.src.y and - third_y < third_geometry.src.y + third_geometry.src.h); -} - -test "PDF native mouse selection, Look, and highlights share page geometry" { - if (!pdf_enabled or platform == .web) return; - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ - .file = "docs/design.pdf", - .cols = 80, - .rows = 24, - }); - defer p.deinit(); - const pane = p.panes[0].?; - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - - // A host without Kitty/native pixels keeps the projected-text contract: - // j/k can still change pages and a body drag remains a generic selection. - const fallback = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 0), fallback.nimages); - p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - const fallback_rect = p.rects[0]; - const fallback_col = fallback_rect.x + config.GUTTER + 1; - const fallback_row = fallback_rect.y + BOX_H + 1; - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); - try std.testing.expectEqual(.done, pane.sel[sel_slot].state); - try std.testing.expect(pane.pdf.?.selection == null); - pane.sel[sel_slot].state = .none; - - p.native_images = true; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - const plain_revision = plain.images[0].?.native.revision; - - var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); - defer found.deinit(gpa); - const quad = found.quads[0].quad; - const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; - const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; - const pv = &pane.pdf.?; - const raster = pdf_pane.rasterForPage(pv, pv.page).?; - const source_x: u32 = @intCast(@min( - raster.iw - 1, - @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))), - )); - const source_y: u32 = @intCast(@min( - raster.ih - 1, - @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))), - )); - - // Bring the known word into the fit-width crop, then invert the shared - // source/destination geometry to the nearest body cell. - var geometry = pdf_pane.paneGeometry(p, pane).?; - if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { - const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h; - const wanted = @min(overflow, source_y -| geometry.src.h / 2); - pv.pan_y = if (overflow == 0) 0 else @intCast( - (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, - ); - geometry = pdf_pane.paneGeometry(p, pane).?; - } - const pixel_x = geometry.dst.x + @as(u32, @intCast( - @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, - )); - const pixel_y = geometry.dst.y + @as(u32, @intCast( - @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, - )); - const r = p.rects[0]; - const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); - const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); - - var selected_col: ?u16 = null; - var selected_row: u16 = 0; - const nearby = [_]i32{ 0, -1, 1, -2, 2 }; - find_word: for (nearby) |dy| for (nearby) |dx| { - const col: u16 = @intCast(std.math.clamp( - base_col + dx, - @as(i32, r.x + config.GUTTER), - @as(i32, r.x + r.w - 1), - )); - const row: u16 = @intCast(std.math.clamp( - base_row + dy, - @as(i32, r.y + BOX_H), - @as(i32, r.y + r.h - 1), - )); - if (!pdf_pane.beginSelection(p, pane, col, row)) continue; - if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { - selected_col = col; - selected_row = row; - break :find_word; - } - }; - const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; - try std.testing.expect(std.ascii.indexOfIgnoreCase( - pardes.pdf_test.heldSelection(p, 0).?, - "Pardes", - ) != null); - - _ = frame.reset(.retain_capacity); - const selected_frame = try p.render(frame.allocator()); - const selected_revision = selected_frame.images[0].?.native.revision; - try std.testing.expect(selected_revision != plain_revision); - - // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the - // same texture generation instead of retransmitting identical pixels. - try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row)); - try std.testing.expect(raster.tried); - _ = frame.reset(.retain_capacity); - const duplicate = try p.render(frame.allocator()); - try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); - - // Delayed native hover retains independent quads/text and changes only - // presentation state. In particular it must not borrow the click path, - // whose page activation and persistent selection are intentional effects - // of a gesture rather than observation. - if (config.look_preview_delay_frames) |delay| { - const active_before = p.active; - const page_before = pv.page; - const scroll_before = pv.document_scroll_y; - const sels_before = pane.sel; - const cursor_before = .{ pane.cur_row, pane.cur_col }; - const modal_before = .{ pane.msel, pane.vsel, pane.nsel }; - const selection_before = pv.selection orelse return error.MissingPdfSelection; - const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads); - defer gpa.free(selection_quads_before); - const selection_text_before = try gpa.dupe(u8, pv.selection_text); - defer gpa.free(selection_text_before); - const selection_anchor_before = pv.selection_anchor; - const selection_head_before = pv.selection_head; - const query_before = try gpa.dupe(u8, pv.search_query); - defer gpa.free(query_before); - const search_hit_before = pv.search_hit; - const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; - const effects_before = p.effects_len; - try std.testing.expect(p.drag == .none); - - const hover_motion = Event{ .mouse = .{ - .button = .none, - .kind = .motion, - .col = word_col, - .row = selected_row, - } }; - p.update(hover_motion); - try std.testing.expect(p.look_hover_wait != null); - for (0..delay) |_| p.update(.tick); - const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview; - try std.testing.expectEqual(@as(usize, 0), hover.probe.page); - try std.testing.expect(hover.probe.quads.len > 0); - try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null); - try std.testing.expect(p.look_hover_preview == null); - try std.testing.expect(p.look_hover_wait == null); - - const decorated = try pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.highlightInput(p, 0, pane), - ); - const page_highlights = decorated.forPage(pv.page, pv.page); - try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind); - try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind); - _ = frame.reset(.retain_capacity); - const hovered_frame = try p.render(frame.allocator()); - const hovered_revision = hovered_frame.images[0].?.native.revision; - try std.testing.expect(hovered_revision != selected_revision); - - p.update(.pointer_leave); - try std.testing.expect(p.pdf_hover_preview == null); - _ = frame.reset(.retain_capacity); - const unhovered_frame = try p.render(frame.allocator()); - try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision); - - try std.testing.expectEqual(active_before, p.active); - try std.testing.expectEqual(page_before, pv.page); - try std.testing.expectEqual(scroll_before, pv.document_scroll_y); - try std.testing.expect(std.meta.eql(sels_before, pane.sel)); - try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); - try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel })); - const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection; - try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads); - try std.testing.expectEqualStrings(selection_text_before, pv.selection_text); - try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor)); - try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head)); - try std.testing.expectEqualStrings(query_before, pv.search_query); - try std.testing.expectEqual(search_hit_before, pv.search_hit); - try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); - try std.testing.expectEqual(effects_before, p.effects_len); - try std.testing.expect(p.drag == .none); - } - - // A native right-click resolves the same MuPDF-snapped word and feeds it - // to Look. Search highlights precede selection highlights so the live - // selection remains visually authoritative where they overlap. - p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); - p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); - try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); - pv.resolveSearch(p.pdf_gpa); - const highlights = (try pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.highlightInput(p, 0, pane), - )).items; - try std.testing.expect(highlights.len > 1); - try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); - try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); - const revision_before_reveal = raster.revision; - const max_document_scroll = @as(f64, @floatFromInt( - pv.document_height -| pdf_pane.paneViewport(p, pane).?.pixel_h, - )); - pv.document_scroll_y = max_document_scroll; - pdf_pane.revealSearch(pv, pdf_pane.paneViewport(p, pane).?, pdf_pane.paneGeometry(p, pane)); - const revealed_scroll = pv.document_scroll_y; - try std.testing.expect(revealed_scroll != max_document_scroll); - try std.testing.expectEqual(revision_before_reveal, raster.revision); - pv.document_scroll_y = max_document_scroll; - pdf_pane.revealSearch( - pv, - pdf_pane.paneViewport(p, pane).?, - pdf_pane.paneGeometry(p, pane), - ); // one shot: subsequent manual pan stays put - try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y); - pv.document_scroll_y = revealed_scroll; - _ = frame.reset(.retain_capacity); - const searched = try p.render(frame.allocator()); - try std.testing.expect(searched.images[0].?.native.revision != selected_revision); - - // Fit and viewport changes can crop a hit which was already revealed. - // Fit remains placement-only. SDL's physical-viewport quality request - // changes with a resize and therefore replaces pixels; Kitty's fixed, - // bandwidth-conscious request retains them. - const revision_before_geometry_change = searched.images[0].?.native.revision; - pv.search_reveal_pending = false; - pdf_pane.toggleFit(pane); - try std.testing.expect(pv.search_reveal_pending); - pv.search_reveal_pending = false; - pdf_pane.toggleFit(pane); // restore the reading-width geometry - try std.testing.expect(pv.search_reveal_pending); - pv.search_reveal_pending = false; - const original_cell_pixels = p.cell_pixels; - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = p.cell_pixels, - } }); - try std.testing.expect(!pv.search_reveal_pending); - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = .{ - .w = original_cell_pixels.w, - .h = original_cell_pixels.h + 1, - }, - } }); - try std.testing.expect(pv.search_reveal_pending); - _ = frame.reset(.retain_capacity); - const geometry_changed = try p.render(frame.allocator()); - if (pdf_raster_policy.match_viewport) - try std.testing.expect(geometry_changed.images[0].?.native.revision != - revision_before_geometry_change) - else - try std.testing.expectEqual( - revision_before_geometry_change, - geometry_changed.images[0].?.native.revision, - ); - p.update(.{ .resize = .{ - .cols = p.screen_w, - .rows = p.screen_h, - .cell_pixels = original_cell_pixels, - } }); - _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); - - // Unsupported text-selection actions are consumed by the PDF adapter: - // they neither invent a range over placeholder cells nor disturb the - // native MuPDF selection. The select-button's no-drag click still clears. - p.update(.{ .key = .{ .cp = 'v' } }); - try std.testing.expect(pv.selection != null); - try std.testing.expect(!pane.vsel.active); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); - p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); - try std.testing.expect(pv.selection == null); - try std.testing.expect(pdf_pane.beginSelection(p, pane, word_col, selected_row)); - - // Native drag events update MuPDF quads and copied text immediately, but - // keep the already transmitted page generation stable until release. - _ = frame.reset(.retain_capacity); - const before_drag = try p.render(frame.allocator()); - const before_drag_revision = before_drag.images[0].?.native.revision; - const drag_col = @min(r.x + r.w - 1, word_col +| 12); - const drag_row = @min(r.y + r.h - 1, selected_row +| 4); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .press, - .col = word_col, - .row = selected_row, - } }); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .drag, - .col = @min(drag_col, word_col +| 4), - .row = @min(drag_row, selected_row +| 2), - } }); - _ = frame.reset(.retain_capacity); - const during_first_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision, - during_first_drag.images[0].?.native.revision, - ); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .drag, - .col = drag_col, - .row = drag_row, - } }); - try std.testing.expect(p.drag.select.pdf.selection_changed); - _ = frame.reset(.retain_capacity); - const during_second_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision, - during_second_drag.images[0].?.native.revision, - ); - p.update(.{ .mouse = .{ - .button = config.select_button, - .kind = .release, - .col = drag_col, - .row = drag_row, - } }); - try std.testing.expect(!raster.tried); - _ = frame.reset(.retain_capacity); - const committed_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - before_drag_revision + 1, - committed_drag.images[0].?.native.revision, - ); - _ = frame.reset(.retain_capacity); - const stable_drag = try p.render(frame.allocator()); - try std.testing.expectEqual( - committed_drag.images[0].?.native.revision, - stable_drag.images[0].?.native.revision, - ); - - const saved_query = try gpa.dupe(u8, pv.search_query); - defer gpa.free(saved_query); - pdf_pane.setPage(p, pane, 1); - try std.testing.expect(pv.selection == null); - try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); - try std.testing.expectEqualStrings(saved_query, pv.search_query); -} - -test "Esc back into a PDF keeps the offset within its page" { - if (!pdf_enabled or platform == .web) return; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const fixture = try pdf_impl.makeOutlineTestPdf(gpa); - defer gpa.free(fixture); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); - var path_buf: [256]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); - - const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); - defer p.deinit(); - p.update(.{ .resize = .{ .cols = 80, .rows = 28 } }); - const doc = p.active; - const dp = p.panes[doc].?; - try std.testing.expect(dp.pdf != null); - pardes.pdf_test.sync(p); - - // Read a little way DOWN the page you are on, then step away. - const pv = &dp.pdf.?; - pv.document_scroll_y += 137; - const mid = pv.document_scroll_y; - pv.scroll_to_page_pending = false; - p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc - pardes.pdf_test.sync(p); - const shell = p.active; - try std.testing.expect(shell != doc); - - p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF - pardes.pdf_test.sync(p); - try std.testing.expectEqual(doc, p.active); - - // The jumps stack records a PDF as its PAGE, so returning revealed the page - // you were already on — and a reveal sets `document_scroll_y` to that page's - // start, throwing away where you had read to inside it. - try std.testing.expectEqual(mid, pv.document_scroll_y); - // Same reveal, the other half: with no valid layout yet it only ARMS the - // snap, so a test that watched the offset alone would not see it coming. - try std.testing.expect(!pv.scroll_to_page_pending); -} diff --git a/src/petscii.zig b/src/petscii.zig deleted file mode 100644 index 136c4ead..00000000 --- a/src/petscii.zig +++ /dev/null @@ -1,446 +0,0 @@ -// PETSCII image rendering — a fallback when the terminal has no kitty graphics, -// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the -// image into character cells and match each cell to the Unicode block/sextant glyph -// + a C64 foreground/background color pair that best reproduces the cell's pixels -// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis), -// so the matcher is unit-testable on its own. -const std = @import("std"); - -// The match palette is supplied by the caller (mode-dependent): the Commodore 64 -// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as -// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode. -// This is Pepto's canonical C64 palette. -pub const commodore = [16][3]u8{ - .{ 0x00, 0x00, 0x00 }, // 0 black - .{ 0xff, 0xff, 0xff }, // 1 white - .{ 0x68, 0x37, 0x2b }, // 2 red - .{ 0x70, 0xa4, 0xb2 }, // 3 cyan - .{ 0x6f, 0x3d, 0x86 }, // 4 purple - .{ 0x58, 0x8d, 0x43 }, // 5 green - .{ 0x35, 0x28, 0x79 }, // 6 blue - .{ 0xb8, 0xc7, 0x6f }, // 7 yellow - .{ 0x6f, 0x4f, 0x25 }, // 8 orange - .{ 0x43, 0x39, 0x00 }, // 9 brown - .{ 0x9a, 0x67, 0x59 }, // 10 light red - .{ 0x44, 0x44, 0x44 }, // 11 dark grey - .{ 0x6c, 0x6c, 0x6c }, // 12 grey - .{ 0x9a, 0xd2, 0x84 }, // 13 light green - .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue - .{ 0x95, 0x95, 0x95 }, // 15 light grey -}; - -const Palette = [16][3]u8; - -// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline -// so the slice handed to the retained vaxis screen outlives the frame) + palette -// fg/bg indices. -pub const Cell = struct { - glyph: [4]u8 = .{ ' ', 0, 0, 0 }, - glen: u3 = 1, - fg: u4 = 1, - bg: u4 = 0, -}; - -pub const Grid = struct { cells: []Cell, gw: usize, gh: usize }; - -fn dist2(a: [3]u8, b: [3]u8) u32 { - const dr = @as(i32, a[0]) - b[0]; - const dg = @as(i32, a[1]) - b[1]; - const db = @as(i32, a[2]) - b[2]; - return @intCast(dr * dr + dg * dg + db * db); -} - -fn nearest(px: [3]u8, pal: Palette) u4 { - var best: u4 = 0; - var bestd: u32 = std.math.maxInt(u32); - for (pal, 0..) |c, i| { - const d = dist2(px, c); - if (d < bestd) { - bestd = d; - best = @intCast(i); - } - } - return best; -} - -// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ---- -const Glyph = struct { cp: u21, bits: u64 }; - -// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right, -// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that -// coincide with existing block characters are mapped to those instead. -fn sextantCp(p: u6) u21 { - return switch (p) { - 0 => ' ', - 21 => 0x258C, // left half ▌ - 42 => 0x2590, // right half ▐ - 63 => 0x2588, // full block █ - else => blk: { - var off: u21 = @as(u21, p) - 1; - if (p > 21) off -= 1; - if (p > 42) off -= 1; - break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants - }, - }; -} - -// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells). -fn sextantBits(p: u6) u64 { - var bits: u64 = 0; - var y: usize = 0; - while (y < 8) : (y += 1) { - const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2 - var x: usize = 0; - while (x < 8) : (x += 1) { - const col: usize = if (x < 4) 0 else 1; - const sub: u6 = @intCast(band * 2 + col); - if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x); - } - } - return bits; -} - -// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap. -fn rows(r: [8]u8) u64 { - var b: u64 = 0; - for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8); - return b; -} - -// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they -// can't express an exact 4-row half or a quarter; these fill that gap. -const block_glyphs = [_]Glyph{ - .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half - .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half - .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL - .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR - .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL - .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR - .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR - .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL - .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR - .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL - .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR - .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL -}; - -// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges. -const line_glyphs = [_]Glyph{ - .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─ - .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │ - .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼ - .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲ - .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱ - .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳ -}; - -// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8 -// console font). This is how petsciinator's charset works: the FULL character set is -// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then -// rendered by its codepoint — the terminal draws the letter in its own font. (bit -// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.) -const ascii_glyphs = [_]Glyph{ - .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // ! - .{ .cp = 0x22, .bits = 0x0000000000246666 }, // " - .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // # - .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $ - .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // % - .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // & - .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // ' - .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // ( - .{ .cp = 0x29, .bits = 0x000c18303030180c }, // ) - .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // * - .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // + - .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // , - .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // - - .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // . - .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // / - .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0 - .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1 - .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2 - .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3 - .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4 - .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5 - .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6 - .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7 - .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8 - .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9 - .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // : - .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ; - .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // < - .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // = - .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // > - .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ? - .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @ - .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A - .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B - .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C - .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D - .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E - .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F - .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G - .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H - .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I - .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J - .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K - .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L - .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M - .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N - .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O - .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P - .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q - .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R - .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S - .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T - .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U - .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V - .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W - .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X - .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y - .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z - .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [ - .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\ - .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ] - .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^ - .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _ - .{ .cp = 0x60, .bits = 0x000000000030180c }, // ` - .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a - .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b - .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c - .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d - .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e - .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f - .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g - .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h - .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i - .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j - .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k - .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l - .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m - .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n - .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o - .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p - .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q - .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r - .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s - .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t - .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u - .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v - .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w - .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x - .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y - .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z - .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // { - .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // | - .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // } - .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~ -}; - -// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at -// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks. -const glyphs = blk: { - @setEvalBranchQuota(100000); - var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined; - var p: usize = 0; - while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) }; - for (block_glyphs, 0..) |bg, i| list[64 + i] = bg; - for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg; - break :blk list; -}; -const glyphs_ascii = glyphs ++ ascii_glyphs; - -// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the -// most-common palette colors among the 64 pixels; for each ordered pair the glyph -// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg). -fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell { - var counts = [_]u16{0} ** 16; - for (cell) |px| counts[nearest(px, pal)] += 1; - var cand: [4]u4 = undefined; - var ncand: usize = 0; - var used = [_]bool{false} ** 16; - while (ncand < 4) : (ncand += 1) { - var best: ?usize = null; - for (counts, 0..) |c, i| { - if (used[i] or c == 0) continue; - if (best == null or c > counts[best.?]) best = i; - } - if (best) |bi| { - cand[ncand] = @intCast(bi); - used[bi] = true; - } else break; - } - if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total - if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color - - var best_cost: i64 = std.math.maxInt(i64); - var best = encode(' ', cand[0], cand[0]); - var fi: usize = 0; - while (fi < ncand) : (fi += 1) { - var bi: usize = 0; - while (bi < ncand) : (bi += 1) { - if (fi == bi) continue; - const fg = cand[fi]; - const bg = cand[bi]; - var dfg: [64]u32 = undefined; - var dbg: [64]u32 = undefined; - var base: i64 = 0; - for (cell, 0..) |px, p| { - dfg[p] = dist2(px, pal[fg]); - dbg[p] = dist2(px, pal[bg]); - base += dbg[p]; - } - for (gset) |g| { - var delta: i64 = 0; - var bits = g.bits; - while (bits != 0) : (bits &= bits - 1) { - const p: usize = @ctz(bits); - delta += @as(i64, dfg[p]) - @as(i64, dbg[p]); - } - const cost = base + delta; - if (cost < best_cost) { - best_cost = cost; - best = encode(g.cp, fg, bg); - } - } - } - } - return best; -} - -fn encode(cp: u21, fg: u4, bg: u4) Cell { - var c = Cell{ .fg = fg, .bg = bg }; - const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1; - c.glen = @intCast(n); - return c; -} - -// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells, -// preserving the image aspect with the terminal cell aspect (~1:2) corrected. -// Caller owns Grid.cells (gpa). -pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid { - if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 }; - const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs; - // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units. - var gw = cols; - var gh = (cols * ih) / (2 * iw); - if (gh > rows_) { - gh = rows_; - gw = (rows_ * 2 * iw) / ih; - } - gw = std.math.clamp(gw, 1, cols); - gh = std.math.clamp(gh, 1, rows_); - - const cells = try gpa.alloc(Cell, gw * gh); - var cy: usize = 0; - while (cy < gh) : (cy += 1) { - const ry0 = cy * ih / gh; - const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh); - var cx: usize = 0; - while (cx < gw) : (cx += 1) { - const rx0 = cx * iw / gw; - const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw); - var cell: [64][3]u8 = undefined; - var sy: usize = 0; - while (sy < 8) : (sy += 1) { - const py0 = ry0 + sy * (ry1 - ry0) / 8; - const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8); - var sx: usize = 0; - while (sx < 8) : (sx += 1) { - const px0 = rx0 + sx * (rx1 - rx0) / 8; - const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8); - var rs: usize = 0; - var gs: usize = 0; - var bs: usize = 0; - var n: usize = 0; - var yy = py0; - while (yy < py1 and yy < ih) : (yy += 1) { - var xx = px0; - while (xx < px1 and xx < iw) : (xx += 1) { - const i = (yy * iw + xx) * 4; - rs += rgba[i]; - gs += rgba[i + 1]; - bs += rgba[i + 2]; - n += 1; - } - } - if (n == 0) n = 1; - cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) }; - } - } - cells[cy * gw + cx] = matchCell(&cell, pal, gset); - } - } - return .{ .cells = cells, .gw = gw, .gh = gh }; -} - -test "sextant codepoints: blocks + endpoints" { - try std.testing.expectEqual(@as(u21, ' '), sextantCp(0)); - try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63)); - try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21)); - try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42)); - try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant - try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant -} - -test "matchCell: solid color -> space on that bg" { - var cell: [64][3]u8 = undefined; - for (&cell) |*p| p.* = commodore[5]; // all green - const m = matchCell(&cell, commodore, &glyphs); - try std.testing.expectEqual(@as(u4, 5), m.bg); - try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]); -} - -test "matchCell: clean top/bottom split picks the two colors" { - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue - const m = matchCell(&cell, commodore, &glyphs); - // both palette colors must be chosen (in some fg/bg order) - const a = @as(u4, @min(m.fg, m.bg)); - const b = @as(u4, @max(m.fg, m.bg)); - try std.testing.expectEqual(@as(u4, 1), a); - try std.testing.expectEqual(@as(u4, 6), b); - // a clean top/bottom split must resolve to a real block glyph (the top-4-rows - // half block ▀), never a blank cell. - const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0; - try std.testing.expectEqual(@as(u21, 0x2580), cp); -} - -test "ascii option only enables the ascii glyphs" { - // the ascii glyph codepoints must be reachable exactly when ascii is on. - var seen_block = false; - var seen_ascii = false; - for (glyphs) |g| if (g.cp == '#') { - seen_block = true; - }; - for (glyphs_ascii) |g| if (g.cp == '#') { - seen_ascii = true; - }; - try std.testing.expect(!seen_block); // '#' is an ascii-only glyph - try std.testing.expect(seen_ascii); - try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len); -} - -test "ascii glyph is chosen when a cell has its exact shape" { - // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S' - // with ascii on (cost 0), and fall back to some block glyph with ascii off. - const s_bits: u64 = 0x003c6630180c663c; - var cell: [64][3]u8 = undefined; - for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0]; - const on = matchCell(&cell, commodore, &glyphs_ascii); - try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0); - const off = matchCell(&cell, commodore, &glyphs); - try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S'); -} - -test "render: tiny image produces a grid within bounds" { - const a = std.testing.allocator; - // 2x2 checker, RGBA - var img = [_]u8{0} ** (2 * 2 * 4); - img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white - img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white - const g = try render(a, &img, 2, 2, 10, 10, commodore, true); - defer a.free(g.cells); - try std.testing.expect(g.gw >= 1 and g.gw <= 10); - try std.testing.expect(g.gh >= 1 and g.gh <= 10); - try std.testing.expectEqual(g.gw * g.gh, g.cells.len); -} diff --git a/src/runtime_config.zig b/src/runtime_config.zig deleted file mode 100644 index 69d2cdfe..00000000 --- a/src/runtime_config.zig +++ /dev/null @@ -1,579 +0,0 @@ -//! Runtime choices in one plain, owned record. -//! -//! `Setting` is compile-time metadata for the builtin registry; `State` is the -//! data it mutates and the Config report reads. Neither contains callbacks. -const std = @import("std"); -const panel_animation = @import("panel_animation.zig"); -const limits = @import("limits.zig"); - -/// Tagline glyphs retain body-cell geometry, so allowing a face larger than -/// the body would clip into neighbouring cells. Zero would make the role -/// invisible. Keep the runtime command on the same 1...100 contract as the -/// build-time default in config.zig. -pub const tagline_percent_min: u8 = 1; -pub const tagline_percent_max: u8 = 100; - -pub fn Text(comptime capacity: usize) type { - return struct { - bytes: [capacity]u8 = @splat(0), - len: std.math.IntFittingRange(0, capacity) = 0, - - pub fn get(value: *const @This()) []const u8 { - return value.bytes[0..value.len]; - } - - pub fn set(value: *@This(), text: []const u8) bool { - if (text.len > capacity) return false; - @memcpy(value.bytes[0..text.len], text); - value.len = @intCast(text.len); - return true; - } - - pub fn clear(value: *@This()) void { - value.len = 0; - } - }; -} - -pub const State = struct { - theme: usize = 0, - colors: bool = true, - wrap: bool = true, - tag_bottom: bool = false, - debug: bool = false, - - /// Empty requested text means config.default_shell. Resolution belongs to - /// the native host (the core deliberately has no filesystem), so retain - /// the executable it actually chose separately and mark a changed request - /// pending until the next terminal spawn acknowledges it. - shell: struct { - requested: Text(255) = .{}, - // One data schema across native, browser and freestanding builds; only - // its one absolute-path field follows `limits.host_path_cap`. - effective: Text(limits.host_path_cap) = .{}, - pending: bool = true, - } = .{}, - - /// The shell acknowledges a font before `effective` changes. A rejected - /// request therefore remains queryable without claiming it is on screen. - font: struct { - requested_path: Text(limits.host_path_cap) = .{}, - requested_name: Text(255) = .{}, - effective_name: Text(255) = .{}, - pending: bool = false, - effective_size_hundredths: u16 = 0, - effective_size_unit: FontSizeUnit = .unknown, - // Pardes.init copies config.gui_tagline_font_percent here. Keeping - // the compiled choice in the live record makes Config truthful while - // avoiding the config -> builtins -> runtime_config import cycle. - tagline_percent: u8 = 100, - } = .{}, - - panel_transition: panel_animation.Transition = .off, - scene_effects: panel_animation.SceneEffect = .{}, - - pub fn toggleTransition(state: *State, effect: panel_animation.Transition) void { - std.debug.assert(effect != .off); - state.panel_transition = if (state.panel_transition == effect) .off else effect; - } -}; - -pub const FontSizeUnit = enum { unknown, pixels, points }; - -/// Replace the requested font tuple atomically. Both fixed strings are -/// preflighted before either changes, so a rejected long name cannot leave a -/// new path paired with stale metadata. -pub fn requestFont(state: *State, path: []const u8, name: []const u8) bool { - if (path.len > state.font.requested_path.bytes.len or - name.len > state.font.requested_name.bytes.len) return false; - std.debug.assert(state.font.requested_path.set(path)); - std.debug.assert(state.font.requested_name.set(name)); - state.font.pending = true; - return true; -} - -/// Backend facilities are plain data supplied once by the platform-facing -/// registry. The same value gates command generation, leader paths, source -/// queries, and the Config report, so "unsupported" cannot mean four subtly -/// different things at those four call sites. -pub const Capabilities = struct { - font_picker: bool, - panel_transitions: bool, - scene_shaders: bool, - /// A smaller tagline face is also supported by the browser, which does - /// not own a native font picker. Keep this fact deliberately independent. - tagline_font_size: bool, -}; - -pub const Capability = std.meta.FieldEnum(Capabilities); - -pub const Toggle = enum { colors, wrap, tag_bottom, debug }; -pub const Scene = std.meta.FieldEnum(panel_animation.SceneEffect); - -pub const Action = union(enum) { - toggle: Toggle, - shell, - theme, - font, - tagline_size, - transition: panel_animation.Transition, - scene: Scene, -}; - -pub const Setting = struct { - word: []const u8, - action: Action, - availability: ?Capability = null, - - pub fn takesArg(setting: Setting) bool { - return switch (setting.action) { - .shell, .theme, .font, .tagline_size => true, - else => false, - }; - } - - pub fn enabled(setting: Setting, capabilities: Capabilities) bool { - const capability = setting.availability orelse return true; - return switch (capability) { - inline else => |field| @field(capabilities, @tagName(field)), - }; - } -}; - -/// This table is both the generated-setting builtin input and the Config -/// report order. Adding mutable config without adding a query row is therefore -/// impossible unless it is deliberately kept out of this user-facing state. -pub const settings = [_]Setting{ - .{ .word = "Colors", .action = .{ .toggle = .colors } }, - .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, - .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, - .{ .word = "Debug", .action = .{ .toggle = .debug } }, - .{ .word = "Theme", .action = .theme }, - .{ .word = "Shell", .action = .shell }, - .{ .word = "Font", .action = .font, .availability = .font_picker }, - .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, - .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, - .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, - .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, - .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, - .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, - .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, - .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, - .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, - .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, - .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, - .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, - .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, - .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, - .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, -}; - -pub fn find(name: []const u8) ?Setting { - for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; - return null; -} - -/// The table is also the sole action-to-command vocabulary. Compile-time -/// callers use this for picker rows; the Config report uses it for the active -/// transition. No parallel enum or transition-name switch can drift. -pub fn findAction(action: Action) ?Setting { - for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; - return null; -} - -fn actionCount(comptime action: Action) comptime_int { - var count = 0; - for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); - return count; -} - -comptime { - @setEvalBranchQuota(20_000); - for (settings, 0..) |setting, i| { - if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); - for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) - @compileError("duplicate runtime setting command word: " ++ setting.word); - switch (setting.action) { - .font, .tagline_size => if (setting.availability != .font_picker) - @compileError("native font settings must use the font-picker capability"), - .transition => if (setting.availability != .panel_transitions) - @compileError("panel effects must use the panel-transition capability"), - .scene => if (setting.availability != .scene_shaders) - @compileError("scene effects must use the scene-shader capability"), - else => if (setting.availability != null) - @compileError("unconditional settings cannot carry a backend capability"), - } - } - for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) - @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); - if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or - actionCount(.tagline_size) != 1) - @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); - for (std.enums.values(panel_animation.Transition)) |effect| { - const expected: comptime_int = @intFromBool(effect != .off); - if (actionCount(.{ .transition = effect }) != expected) - @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); - } - for (std.enums.values(Scene)) |effect| { - if (actionCount(.{ .scene = effect }) != 1) - @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); - if (@FieldType(panel_animation.SceneEffect, @tagName(effect)) != bool) - @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); - } -} - -/// Apply settings whose operation is independent of themes, font discovery, -/// or a shell. Those three remain explicit at the generated builtin's edge. -pub fn applySimple(state: *State, setting: Setting, argument: ?[]const u8) bool { - switch (setting.action) { - .toggle => |field| switch (field) { - .colors => state.colors = !state.colors, - .wrap => state.wrap = !state.wrap, - .tag_bottom => state.tag_bottom = !state.tag_bottom, - .debug => state.debug = !state.debug, - }, - .shell => { - const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); - if (value.len == 0 or !state.shell.requested.set(value)) return false; - state.shell.pending = true; - }, - .tagline_size => { - const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); - const percent = std.fmt.parseInt(u16, text, 10) catch return false; - if (percent < tagline_percent_min or percent > tagline_percent_max) return false; - // Parse and validate before the sole write: malformed commands - // cannot partially alter the live configuration. - state.font.tagline_percent = @intCast(percent); - }, - .transition => |effect| state.toggleTransition(effect), - .scene => |effect| switch (effect) { - inline else => |field| { - const value = &@field(state.scene_effects, @tagName(field)); - value.* = !value.*; - }, - }, - .theme, .font => return false, - } - return true; -} - -/// The runtime facts which do not belong to mutable `State`, supplied by the -/// core when it materialises +Config. Slices are borrowed for this one write. -pub const ReportContext = struct { - startup_config_path: ?[]const u8, - platform: []const u8, - theme_name: []const u8, - compiled_default_shell: []const u8, - /// Null outside the SDL GUI. The borrowed label comes from effect_sources, - /// which knows whether its embedded GLSL import is live or paired prebuilt. - gui_shader_source_mode: ?[]const u8 = null, - hover_delay_frames: ?u16, - native_images: bool, - capabilities: Capabilities, - state: *const State, -}; - -fn onOff(value: bool) []const u8 { - return if (value) "on" else "off"; -} - -fn shown(text: []const u8) []const u8 { - return if (text.len == 0) "(none)" else text; -} - -/// Name the setting which selected the one active transition. This keeps the -/// report vocabulary identical to the generated builtin registry. -fn transitionSettingName(transition: panel_animation.Transition) []const u8 { - if (transition == .off) return "off"; - return findAction(.{ .transition = transition }).?.word; -} - -/// Write the complete live Config report without allocation. Setting-backed -/// rows follow `settings` order; host facts follow them as a compact footer. -pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { - const state = context.state; - var wrote_transition = false; - for (settings) |setting| switch (setting.action) { - .toggle => |field| { - const value = switch (field) { - .colors => state.colors, - .wrap => state.wrap, - .tag_bottom => state.tag_bottom, - .debug => state.debug, - }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); - }, - .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), - .shell => { - const chosen = state.shell.requested.get(); - try out.print( - "{s} requested (new panes): {s}{s}\n" ++ - "{s} effective (last spawn): {s}\n" ++ - "{s} pending: {s}\n", - .{ - setting.word, - if (chosen.len == 0) context.compiled_default_shell else chosen, - if (chosen.len == 0) " (default)" else "", - setting.word, - shown(state.shell.effective.get()), - setting.word, - onOff(state.shell.pending), - }, - ); - }, - .font => if (!setting.enabled(context.capabilities)) - try out.print("{s}: unsupported\n", .{setting.word}) - else - try out.print( - "{s} requested: {s}\n" ++ - "{s} requested path: {s}\n" ++ - "{s} effective: {s}\n" ++ - "{s} pending: {s}\n" ++ - "{s} effective size: {d}.{d:0>2} {s}\n", - .{ - setting.word, - shown(state.font.requested_name.get()), - setting.word, - shown(state.font.requested_path.get()), - setting.word, - shown(state.font.effective_name.get()), - setting.word, - onOff(state.font.pending), - setting.word, - state.font.effective_size_hundredths / 100, - state.font.effective_size_hundredths % 100, - @tagName(state.font.effective_size_unit), - }, - ), - .tagline_size => if (!context.capabilities.tagline_font_size) - try out.print("{s}: unsupported\n", .{setting.word}) - else if (!setting.enabled(context.capabilities)) - try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) - else - try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), - .transition => { - if (wrote_transition) continue; - wrote_transition = true; - if (!setting.enabled(context.capabilities)) - try out.writeAll("Panel transition: unsupported\n") - else - try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); - }, - .scene => |effect| { - if (!setting.enabled(context.capabilities)) { - try out.print("{s}: unsupported\n", .{setting.word}); - continue; - } - const enabled = switch (effect) { - inline else => |field| @field(state.scene_effects, @tagName(field)), - }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); - }, - }; - - if (context.startup_config_path) |path| - try out.print("Startup config: {s}\n", .{path}) - else - try out.writeAll("Startup config: no per-user config path\n"); - try out.print( - "Platform: {s}\n" ++ - "Compiled default shell: {s}\n", - .{ context.platform, context.compiled_default_shell }, - ); - if (context.gui_shader_source_mode) |mode| - try out.print("GUI shader source: {s}\n", .{mode}); - if (context.hover_delay_frames) |frames| - try out.print("Look hover delay: {d} frames\n", .{frames}) - else - try out.writeAll("Look hover delay: off\n"); - try out.print("Native images: {s}\n", .{onOff(context.native_images)}); -} - -test "setting names are unique and argument metadata follows actions" { - for (settings, 0..) |setting, i| { - try std.testing.expect(setting.word.len > 0); - for (settings[i + 1 ..]) |later| - try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); - try std.testing.expectEqual(switch (setting.action) { - .shell, .theme, .font, .tagline_size => true, - else => false, - }, setting.takesArg()); - } -} - -test "simple setting application mutates only its plain field" { - var state: State = .{}; - try std.testing.expect(applySimple(&state, find("Colors").?, null)); - try std.testing.expect(!state.colors); - try std.testing.expect(applySimple(&state, find("Shell").?, " fish\n")); - try std.testing.expectEqualStrings("fish", state.shell.requested.get()); - try std.testing.expect(state.shell.pending); - try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(panel_animation.Transition.ascii, state.panel_transition); - try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(panel_animation.Transition.off, state.panel_transition); - try std.testing.expect(applySimple(&state, find("Crt").?, null)); - try std.testing.expect(state.scene_effects.crt); -} - -test "tagline size validates before mutating live state" { - const setting = find("TaglineSize").?; - var state: State = .{}; - - for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { - try std.testing.expect(applySimple(&state, setting, argument)); - try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); - } - - state.font.tagline_percent = 67; - for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { - try std.testing.expect(!applySimple(&state, setting, argument)); - try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); - } -} - -test "font request tuple rejects atomically" { - var state: State = .{}; - try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); - var too_long: [256]u8 = @splat('x'); - try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); - try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); - try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); -} - -test "Config report observes every simple setting and all live context" { - var state: State = .{}; - var storage: [4096]u8 = undefined; - const context: ReportContext = .{ - .startup_config_path = "/tmp/pardes/init", - .platform = "gui", - .theme_name = "acme", - .compiled_default_shell = "/bin/sh", - .gui_shader_source_mode = "live GLSL compiled during this build", - .hover_delay_frames = 18, - .native_images = true, - .capabilities = .{ - .font_picker = true, - .panel_transitions = true, - .scene_shaders = true, - .tagline_font_size = true, - }, - .state = &state, - }; - - for (settings) |setting| { - switch (setting.action) { - .theme, .font => continue, - else => {}, - } - const argument: ?[]const u8 = switch (setting.action) { - .shell => "fish", - .tagline_size => "73", - else => null, - }; - try std.testing.expect(applySimple(&state, setting, argument)); - - var out: std.Io.Writer = .fixed(&storage); - try writeReport(&out, context); - const report = storage[0..out.end]; - const expected = switch (setting.action) { - .toggle => |field| switch (field) { - .colors => "Colors: off\n", - .wrap => "Wrap: off\n", - .tag_bottom => "Tagbottom: on\n", - .debug => "Debug: on\n", - }, - .shell => "Shell requested (new panes): fish\n", - .tagline_size => "TaglineSize: 73%\n", - .transition => |transition| switch (transition) { - .off => unreachable, - .slide => "Panel transition: PanelSlide\n", - .zoom => "Panel transition: PanelZoom\n", - .dissolve => "Panel transition: PanelDissolve\n", - .ascii => "Panel transition: PanelAscii\n", - .vertical => "Panel transition: PanelVertical\n", - .edges => "Panel transition: PanelEdges\n", - .fall => "Panel transition: PanelFall\n", - .wave => "Panel transition: PanelWave\n", - .curtain => "Panel transition: PanelCurtain\n", - .scramble => "Panel transition: PanelScramble\n", - .typewriter => "Panel transition: PanelType\n", - }, - .scene => |effect| switch (effect) { - .crt => "Crt: on\n", - .ripple => "Ripple: on\n", - .glitch => "Glitch: on\n", - }, - .theme, .font => unreachable, - }; - try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); - } - - try std.testing.expect(state.font.requested_name.set("Wanted Mono")); - try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); - try std.testing.expect(state.font.effective_name.set("Effective Mono")); - state.font.pending = true; - state.font.effective_size_hundredths = 1375; - state.font.effective_size_unit = .points; - state.font.tagline_percent = 82; - - var out: std.Io.Writer = .fixed(&storage); - try writeReport(&out, context); - const report = storage[0..out.end]; - for ([_][]const u8{ - "Theme: acme\n", - "Font requested: Wanted Mono\n", - "Font requested path: /fonts/wanted.ttf\n", - "Font effective: Effective Mono\n", - "Font pending: on\n", - "Font effective size: 13.75 points\n", - "TaglineSize: 82%\n", - "Startup config: /tmp/pardes/init\n", - "Platform: gui\n", - "Compiled default shell: /bin/sh\n", - "GUI shader source: live GLSL compiled during this build\n", - "Look hover delay: 18 frames\n", - "Native images: on\n", - }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); - - var defaults: State = .{}; - var defaults_context = context; - defaults_context.startup_config_path = null; - defaults_context.platform = "tty"; - defaults_context.gui_shader_source_mode = null; - defaults_context.hover_delay_frames = null; - defaults_context.native_images = false; - defaults_context.capabilities = .{ - .font_picker = false, - .panel_transitions = true, - .scene_shaders = false, - .tagline_font_size = false, - }; - defaults_context.state = &defaults; - out = .fixed(&storage); - try writeReport(&out, defaults_context); - const defaults_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); - - defaults_context.platform = "web"; - defaults_context.capabilities.panel_transitions = false; - defaults_context.capabilities.tagline_font_size = true; - out = .fixed(&storage); - try writeReport(&out, defaults_context); - const web_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); -} diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index 8e721751..e1a42962 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -4,6 +4,7 @@ //! `runOne` on that worker. No subprocess or borrowed core memory reaches the //! editor/event-loop thread. const std = @import("std"); +const filesystem = @import("fs.zig"); /// A deliberately finite answer. One selection cannot retain more than 1 MiB /// and a multi-selection command cannot retain more than 4 MiB in total. @@ -43,7 +44,7 @@ pub const Job = struct { .inputs = &.{}, }; errdefer gpa.free(job.command); - job.cwd = try gpa.dupe(u8, request.cwd); + job.cwd = try gpa.dupe(u8, filesystem.localPath(request.cwd) orelse request.cwd); errdefer gpa.free(job.cwd); job.inputs = try gpa.alloc([]u8, request.inputs.len); errdefer gpa.free(job.inputs); @@ -325,6 +326,29 @@ pub fn runJob(gpa: std.mem.Allocator, io: std.Io, job: *const Job) Response { return response; } +test "native selection filters use the physical directory of an explicit OS mount" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var directory_buf: [4096]u8 = undefined; + const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; + var declared_buf: [4102]u8 = undefined; + const declared = try std.fmt.bufPrint(&declared_buf, "/n/os{s}", .{directory}); + const job = try Job.copy(gpa, .{ .id = 1, .command = "pwd", .cwd = declared, .inputs = &.{.{ .bytes = "" }} }); + defer job.deinit(gpa); + try std.testing.expectEqualStrings(directory, job.cwd); + switch (runOne(gpa, std.testing.io, job.command, job.cwd, "")) { + .ok => |bytes| { + defer gpa.free(bytes); + try std.testing.expectEqualStrings(directory, std.mem.trimEnd(u8, bytes, "\n")); + }, + .failed => |failure| { + gpa.free(failure.stderr); + return error.FilterFailed; + }, + } +} + test "native pipe runner preserves stdin/stdout bytes and reports how it failed" { const gpa = std.testing.allocator; const io = std.testing.io; diff --git a/src/shell_bin.zig b/src/shell_bin.zig deleted file mode 100644 index 1090bb2b..00000000 --- a/src/shell_bin.zig +++ /dev/null @@ -1,567 +0,0 @@ -//! Turning the name of a shell into something a freshly forked child can exec, -//! and into the argv that hands that shell its prompt marks. -//! -//! Native-shell side, like temp_file.zig and message.zig, and for the same -//! reason: it touches the filesystem, and the core does not. The core carries -//! only the NAME (Pardes.shellBin, what the Shell builtin was given); which -//! family that is, what to write for it, where to write it and where the -//! binary actually lives all live here, and both frontends call it rather than -//! keeping a copy each. Nothing here imports the core, which is also what lets -//! it be its own std-only test module. -//! -//! Each host owns one `PromptRcs` for its lifetime. Its files are private -//! `mkstemp` names, completely written and closed before resolve can expose -//! them to a child. Concurrent launches therefore share neither a pathname nor -//! an inode, and a shell can never source another user's predictable /tmp file. -//! -//! ALL OF THIS RUNS IN THE PARENT. Between fork and exec a process may not -//! allocate, and a $PATH search does — which is the same reason the exec is -//! `execv` on an absolute path and never `execvp`. So the lookup is a handful -//! of `access` calls over the directories a shell actually lives in, done -//! before the fork, into a caller buffer that the child then inherits through -//! its copy of the stack. -const std = @import("std"); -const builtin = @import("builtin"); - -const libc = std.c; -const X_OK: c_int = 1; - -extern "c" fn mkstemp(template: [*:0]u8) c_int; -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; - -// ------------------------------------------------- the GUI launch's PATH - -/// Bounded storage for the composed PATH. /etc/paths and /etc/paths.d hold ten -/// directories on a stock machine and a handful more with third-party -/// packages; 4 KiB is not a limit anyone will meet, and a fixed buffer keeps -/// this callable from a host that has not built an allocator yet. -const path_capacity = 4096; -const max_path_files = 64; - -/// macOS: give the PROCESS the PATH a login session would have, but only when -/// it plainly has not got one. -/// -/// A GUI launch — Finder, the Dock, `open(1)` — inherits launchd's -/// environment, and launchd's PATH is `/usr/bin:/bin:/usr/sbin:/sbin`. Nothing -/// else: no /opt/homebrew/bin, no /usr/local/bin. A launch from a terminal -/// inherits the shell's PATH and is fine. That difference is the whole bug, -/// and it is why it reads as intermittent — the same build finds `yazi` when -/// you start it from a terminal and cannot find it when you start it from the -/// Dock. -/// -/// macOS's own answer is /usr/libexec/path_helper, which reads /etc/paths and -/// /etc/paths.d. LOGIN shells run it and non-login shells do not, and pardes -/// spawns non-login shells deliberately (see `resolve`) — so a pane cannot fix -/// this for itself. Nor should it: one environ is inherited by every pty shell -/// pardes forks, every `/bin/sh -c` filter, and every language server the LSP -/// client spawns, and `binOf` searching a launchd PATH is a rust-analyzer that -/// is never found. Fixing the process fixes all of them at once. -/// -/// ONLY when every entry already in PATH is a system directory. That is the -/// test for "nobody configured this". path_helper appends pre-existing entries -/// AFTER the system set, so running it over a real session's PATH would demote -/// a version manager's shims behind /usr/bin and quietly change which `node` -/// runs. A configured PATH is left exactly as it is; the launchd case is -/// unambiguous and is the only one touched. -pub fn adoptSystemPath() void { - if (comptime builtin.os.tag != .macos) return; - var buf: [path_capacity]u8 = undefined; - var len: usize = 0; - collectSystemPath(&buf, &len); - if (len == 0) return; - const system = buf[0..len]; - - const current: []const u8 = if (libc.getenv("PATH")) |p| std.mem.span(p) else ""; - if (!allEntriesWithin(current, system)) return; - if (std.mem.eql(u8, current, system)) return; - - var out: [path_capacity:0]u8 = undefined; - if (len >= out.len) return; - @memcpy(out[0..len], system); - out[len] = 0; - _ = setenv("PATH", out[0..len :0].ptr, 1); -} - -/// Everything a native shell must do TO THE PROCESS before it forks its first -/// pane, in the order it has to happen, handing back the prompt files those -/// forks will borrow. -/// -/// Four hosts performed this ritual by hand and the copies had already -/// diverged. detached/server.zig forks bash through `resolve` exactly like its -/// siblings and never set BASH_SILENCE_DEPRECATION_WARNING, so every pane in a -/// detached session on macOS opened with Apple's zsh-migration banner printed -/// across the top of it — and nobody noticed, because the three hosts anyone -/// looks at daily all had the line. That is the failure mode of a four-line -/// ritual written four times. -/// -/// The ORDER is the content here. `adoptSystemPath` has to precede the fork -/// because the child inherits the environ; the setenv has to precede bash -/// because bash reads it at startup and the rc file is already too late; and -/// the rc files have to be complete on disk before any child can be handed a -/// path to one. -pub fn prepareForFork() PromptRcs { - adoptSystemPath(); - if (comptime builtin.os.tag.isDarwin()) - _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - return PromptRcs.init(); -} - -/// /etc/paths, then every file in /etc/paths.d in NAME ORDER, which is the -/// order path_helper reads them in and therefore the order the directories -/// take precedence in. -fn collectSystemPath(buf: []u8, len: *usize) void { - var file_buf: [path_capacity]u8 = undefined; - if (readSmall("/etc/paths", &file_buf)) |body| appendLines(buf, len, body); - - const io = std.Io.Threaded.global_single_threaded.io(); - var dir = std.Io.Dir.cwd().openDir(io, "/etc/paths.d", .{ .iterate = true }) catch return; - defer dir.close(io); - - // readdir order is undefined and path_helper's is not, so the names are - // collected and sorted before any of them is read. - var names: [max_path_files][256]u8 = undefined; - var name_lens: [max_path_files]usize = undefined; - var count: usize = 0; - var it = dir.iterate(); - while (count < names.len) { - const entry = (it.next(io) catch break) orelse break; - if (entry.kind == .directory) continue; - if (entry.name.len == 0 or entry.name.len > names[count].len) continue; - @memcpy(names[count][0..entry.name.len], entry.name); - name_lens[count] = entry.name.len; - count += 1; - } - var order: [max_path_files]usize = undefined; - for (0..count) |i| order[i] = i; - std.mem.sort(usize, order[0..count], Names{ .names = &names, .lens = &name_lens }, Names.lessThan); - - var path_buf: [512]u8 = undefined; - for (order[0..count]) |i| { - const name = names[i][0..name_lens[i]]; - const path = std.fmt.bufPrintSentinel(&path_buf, "/etc/paths.d/{s}", .{name}, 0) catch continue; - if (readSmall(path, &file_buf)) |body| appendLines(buf, len, body); - } -} - -const Names = struct { - names: *const [max_path_files][256]u8, - lens: *const [max_path_files]usize, - - fn lessThan(self: Names, a: usize, b: usize) bool { - return std.mem.order(u8, self.names[a][0..self.lens[a]], self.names[b][0..self.lens[b]]) == .lt; - } -}; - -/// One directory per line, blanks and whitespace ignored — the format both -/// files use and the only thing path_helper reads out of them. -fn appendLines(buf: []u8, len: *usize, body: []const u8) void { - var lines = std.mem.splitScalar(u8, body, '\n'); - while (lines.next()) |raw| appendEntry(buf, len, std.mem.trim(u8, raw, " \t\r")); -} - -/// Append `entry` unless it is already present. Dedup preserves the FIRST -/// occurrence, which is what makes the order above mean precedence. -fn appendEntry(buf: []u8, len: *usize, entry: []const u8) void { - if (entry.len == 0) return; - if (hasEntry(buf[0..len.*], entry)) return; - const separator: usize = if (len.* == 0) 0 else 1; - if (len.* + separator + entry.len > buf.len) return; - if (separator == 1) { - buf[len.*] = ':'; - len.* += 1; - } - @memcpy(buf[len.*..][0..entry.len], entry); - len.* += entry.len; -} - -fn hasEntry(list: []const u8, entry: []const u8) bool { - var it = std.mem.tokenizeScalar(u8, list, ':'); - while (it.next()) |have| if (std.mem.eql(u8, have, entry)) return true; - return false; -} - -/// Whether `candidate` holds nothing `list` does not. An empty candidate is -/// within any list: a process with no PATH at all is the launchd case too. -fn allEntriesWithin(candidate: []const u8, list: []const u8) bool { - var it = std.mem.tokenizeScalar(u8, candidate, ':'); - while (it.next()) |entry| if (!hasEntry(list, entry)) return false; - return true; -} - -fn readSmall(path: [:0]const u8, buf: []u8) ?[]const u8 { - const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); - if (fd < 0) return null; - defer _ = libc.close(fd); - var off: usize = 0; - while (off < buf.len) { - const n = libc.read(fd, buf[off..].ptr, buf.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return null; - } - if (n == 0) break; - off += @intCast(n); - } - return buf[0..off]; -} - -test "the launchd PATH is replaced and a configured one is left alone" { - var buf: [256]u8 = undefined; - var len: usize = 0; - appendEntry(&buf, &len, "/usr/bin"); - appendEntry(&buf, &len, "/bin"); - appendEntry(&buf, &len, "/usr/bin"); // already there: dedup keeps the first - appendEntry(&buf, &len, ""); - try std.testing.expectEqualStrings("/usr/bin:/bin", buf[0..len]); - - // Exactly the launchd default, in any order: nothing here is a choice. - try std.testing.expect(allEntriesWithin("/usr/bin:/bin", "/usr/bin:/bin:/sbin")); - try std.testing.expect(allEntriesWithin("", "/usr/bin")); - // One entry nobody could have inherited by accident, and the whole PATH is - // off limits — reordering it behind /usr/bin is how a version manager stops - // deciding which `node` runs. - try std.testing.expect(!allEntriesWithin("/Users/x/.cargo/bin:/usr/bin", "/usr/bin:/bin")); - try std.testing.expect(!allEntriesWithin("/opt/homebrew/bin", "/usr/bin:/bin")); -} - -test "the composed system path is the real one, in path_helper's order" { - if (comptime builtin.os.tag != .macos) return; - var buf: [path_capacity]u8 = undefined; - var len: usize = 0; - collectSystemPath(&buf, &len); - const composed = buf[0..len]; - // /etc/paths exists on every mac and leads with these. - try std.testing.expect(hasEntry(composed, "/usr/bin")); - try std.testing.expect(hasEntry(composed, "/bin")); - // ...and its entries come before anything /etc/paths.d contributes, which - // is the precedence the order encodes. - try std.testing.expect(std.mem.startsWith(u8, composed, "/usr/local/bin:")); - // No duplicates: /etc/paths.d files routinely repeat a system directory. - var seen = std.mem.tokenizeScalar(u8, composed, ':'); - var index: usize = 0; - while (seen.next()) |entry| : (index += 1) { - var rest = std.mem.tokenizeScalar(u8, composed, ':'); - var matches: usize = 0; - while (rest.next()) |other| if (std.mem.eql(u8, other, entry)) { - matches += 1; - }; - try std.testing.expectEqual(@as(usize, 1), matches); - } -} - -/// Prompt integration, per shell FAMILY rather than per binary: pardes hides -/// prompt rows, moves the cursor by clicking one, and tells a command's output -/// from the line that asked for it, and all three read the OSC 133 marks a -/// shell has to be talked into emitting. Every family needs different words -/// for the same four marks and a different way to be handed them, so the -/// binary a pane is about to exec picks one of these and there is nothing to -/// configure. -pub const ShellRc = enum { bash, fish, none }; - -/// Which family a shell binary belongs to, by the BASENAME's prefix — the -/// whole heuristic. A prefix and not an exact match because a real system -/// spells them `bash`, `/usr/bin/bash`, `bash-5.2`, `fish-3.7`, and pinning -/// exact names would mean a list to maintain against other people's packaging. -/// It costs a false positive on a program called `fishing`, which is a shell -/// nobody has. -/// -/// `none` is not a failure: it execs the binary plain and the pane works, it -/// just has no prompt marks, so prompts are not hidden and a click on one does -/// not move the shell's cursor. Everything else about the pane is unaffected. -/// -/// ponytail: two families and a fallback. zsh is the obvious third and is NOT -/// here because it is shaped differently — it has no `--rcfile`, so it needs a -/// whole ZDOTDIR directory staged with a .zshrc that re-sources the user's, -/// plus an env var set before exec. Add it when someone runs zsh in pardes and -/// misses prompt hiding; the rc text itself is four lines (precmd/preexec). -pub fn shellRc(bin: []const u8) ShellRc { - const slash = std.mem.lastIndexOfScalar(u8, bin, '/'); - const base = if (slash) |s| bin[s + 1 ..] else bin; - if (std.mem.startsWith(u8, base, "bash")) return .bash; - if (std.mem.startsWith(u8, base, "fish")) return .fish; - return .none; -} - -const bash_rc = - \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' - \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" - \\trap 'printf "\e]133;C\a"' DEBUG - \\ -; - -/// fish is handed this with `-C`, which runs AFTER config.fish — and it has to, -/// because the first thing it does is copy the user's own `fish_prompt` to call -/// it from the middle of ours. Loaded any earlier it would copy the default and -/// silently replace whatever the user actually configured. -/// -/// The other half is why there is no `source ~/.config/fish/config.fish` line -/// the way the bash rc sources .bashrc: bash is being started with `--rcfile`, -/// which REPLACES its startup file, so the rc has to put it back. `-C` adds to -/// fish's startup instead of standing in for it. -/// -/// C and D come off fish's own `fish_preexec`/`fish_postexec` events rather -/// than being spliced into the prompt, which is what bash's DEBUG trap is -/// working around. -const fish_rc = - \\functions -c fish_prompt __pardes_user_prompt - \\function fish_prompt - \\ printf '\e]133;A;cl=line\a' - \\ __pardes_user_prompt - \\ printf '\e]133;B\a' - \\end - \\function __pardes_preexec --on-event fish_preexec - \\ printf '\e]133;C\a' - \\end - \\function __pardes_postexec --on-event fish_postexec - \\ printf '\e]133;D\a' - \\end - \\ -; - -const rc_path_capacity = 64; - -/// The two complete, private prompt files a native host lends to every shell -/// it spawns. No allocation and no global name: moving this value is safe -/// because it stores lengths, never pointers into its own buffers. -pub const PromptRcs = struct { - bash_path: [rc_path_capacity:0]u8 = @splat(0), - bash_len: u8 = 0, - fish_path: [rc_path_capacity:0]u8 = @splat(0), - fish_len: u8 = 0, - fish_command: [rc_path_capacity + "source ".len:0]u8 = @splat(0), - fish_command_len: u8 = 0, - - pub fn init() PromptRcs { - var rcs: PromptRcs = .{}; - rcs.bash_len = stage(&rcs.bash_path, "/tmp/pardes-osc133-bash-XXXXXX", bash_rc); - rcs.fish_len = stage(&rcs.fish_path, "/tmp/pardes-osc133-fish-XXXXXX", fish_rc); - if (rcs.fishPath()) |path| { - const command = std.fmt.bufPrintSentinel(&rcs.fish_command, "source {s}", .{path}, 0) catch { - _ = libc.unlink(path.ptr); - rcs.fish_len = 0; - return rcs; - }; - rcs.fish_command_len = @intCast(command.len); - } - return rcs; - } - - pub fn deinit(rcs: *PromptRcs) void { - if (rcs.bashPath()) |path| _ = libc.unlink(path.ptr); - if (rcs.fishPath()) |path| _ = libc.unlink(path.ptr); - rcs.bash_len = 0; - rcs.fish_len = 0; - rcs.fish_command_len = 0; - } - - fn bashPath(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.bash_len == 0) return null; - return rcs.bash_path[0..rcs.bash_len :0]; - } - - fn fishPath(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.fish_len == 0) return null; - return rcs.fish_path[0..rcs.fish_len :0]; - } - - fn fishCommand(rcs: *const PromptRcs) ?[:0]const u8 { - if (rcs.fish_command_len == 0) return null; - return rcs.fish_command[0..rcs.fish_command_len :0]; - } -}; - -/// Create one private 0600 file and reveal its length only after the complete -/// write and close. Failure leaves no pathname for resolve to hand to a shell. -fn stage(path_buf: *[rc_path_capacity:0]u8, template: []const u8, contents: []const u8) u8 { - const path = std.fmt.bufPrintSentinel(path_buf, "{s}", .{template}, 0) catch return 0; - const fd = mkstemp(path.ptr); - if (fd < 0) return 0; - var off: usize = 0; - while (off < contents.len) { - const n = libc.write(fd, contents[off..].ptr, contents.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - _ = libc.close(fd); - _ = libc.unlink(path.ptr); - return 0; - } - if (n == 0) { - _ = libc.close(fd); - _ = libc.unlink(path.ptr); - return 0; - } - off += @intCast(n); - } - if (libc.close(fd) != 0) { - _ = libc.unlink(path.ptr); - return 0; - } - return @intCast(path.len); -} - -test "shell family is the basename's prefix, and anything else runs unadorned" { - try std.testing.expectEqual(ShellRc.fish, shellRc("fish")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/bin/fish")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/opt/homebrew/bin/fish")); - try std.testing.expectEqual(ShellRc.bash, shellRc("bash")); - try std.testing.expectEqual(ShellRc.bash, shellRc("/bin/bash")); - // packaged with a version on the end, which is why this is a prefix - try std.testing.expectEqual(ShellRc.bash, shellRc("/usr/bin/bash-5.2")); - try std.testing.expectEqual(ShellRc.fish, shellRc("/usr/local/bin/fish-3.7")); - // a directory that merely CONTAINS the word is not the shell's name - try std.testing.expectEqual(ShellRc.none, shellRc("/opt/fish/bin/nu")); - // no marks, still a shell - try std.testing.expectEqual(ShellRc.none, shellRc("/usr/bin/zsh")); - try std.testing.expectEqual(ShellRc.none, shellRc("/bin/sh")); - try std.testing.expectEqual(ShellRc.none, shellRc("nu")); - try std.testing.expectEqual(ShellRc.none, shellRc("")); -} - -/// The directories a shell binary is actually installed in. Not $PATH: see the -/// header. `/opt/homebrew` and `/opt/local` are where a mac keeps the shells -/// that did not ship with it, which is every shell anyone chooses on purpose. -const bin_dirs = [_][]const u8{ - "/usr/bin/", - "/bin/", - "/usr/local/bin/", - "/opt/homebrew/bin/", - "/opt/local/bin/", - "/usr/sbin/", -}; - -/// Last resorts, in order, when the configured shell is not installed: the -/// shell pardes used to hardcode, then the one POSIX says exists. A pane that -/// opens with the wrong shell beats a pane whose child dies at exec and shows -/// nothing but an immediate EOF. -const fallbacks = [_][]const u8{ - if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash", - "/bin/sh", -}; - -pub const Spawn = struct { - path: [*:0]const u8, - /// argv for execv. Shorter forms stop at their first null, which is what - /// execv reads anyway, so one width covers all three families. - argv: [4:null]?[*:0]const u8, -}; - -/// `bin` is whatever the Shell builtin was given — a bare name to look up, or -/// a path (anything with a `/`) to take at its word. `buf` holds the resolved -/// path for as long as the returned Spawn is used, which for a caller that is -/// about to fork means: until the child execs. `prompt_rcs` is host-lifetime -/// storage and must likewise remain alive through that exec. -pub fn resolve(bin: []const u8, buf: *[std.fs.max_path_bytes]u8, prompt_rcs: *const PromptRcs) Spawn { - const path = find(bin, buf) orelse fallback(buf); - // the family comes off the path that will ACTUALLY be executed, not the - // name that was asked for — `Shell sh` on a system where that is a symlink - // to bash still has no `--rcfile` promise attached to it, and a resolved - // /usr/bin/fish reads as fish whether it was reached by name or by path - const marks: [2]?[*:0]const u8 = switch (shellRc(std.mem.span(path))) { - .bash => if (prompt_rcs.bashPath()) |rc| .{ "--rcfile", rc.ptr } else .{ null, null }, - // -C runs AFTER config.fish, which is the whole point (see fish_rc) - .fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null }, - .none => .{ null, null }, - }; - return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } }; -} - -fn find(bin: []const u8, buf: *[std.fs.max_path_bytes]u8) ?[*:0]const u8 { - if (bin.len == 0 or bin.len + 1 > buf.len) return null; - if (std.mem.indexOfScalar(u8, bin, '/') != null) { - @memcpy(buf[0..bin.len], bin); - buf[bin.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - return if (libc.access(p, X_OK) == 0) p else null; - } - for (bin_dirs) |dir| { - if (dir.len + bin.len + 1 > buf.len) continue; - @memcpy(buf[0..dir.len], dir); - @memcpy(buf[dir.len..][0..bin.len], bin); - buf[dir.len + bin.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - if (libc.access(p, X_OK) == 0) return p; - } - return null; -} - -fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 { - for (fallbacks) |f| { - @memcpy(buf[0..f.len], f); - buf[f.len] = 0; - const p: [*:0]const u8 = @ptrCast(buf); - if (libc.access(p, X_OK) == 0) return p; - } - // nothing executable anywhere we know to look: exec will fail and the pane - // will show an immediate EOF, which is the honest report of that machine. - // buf already holds the last candidate, NUL and all. - return @ptrCast(buf); -} - -test "a path is taken at its word, a name is looked up, and both pick their own marks" { - if (builtin.os.tag == .windows) return; - var buf: [std.fs.max_path_bytes]u8 = undefined; - var prompt_rcs = PromptRcs.init(); - defer prompt_rcs.deinit(); - - // /bin/sh exists on every unix this builds for and is in no family, so it - // pins the resolve-by-path arm AND the unadorned argv - const sh = resolve("/bin/sh", &buf, &prompt_rcs); - try std.testing.expectEqualStrings("/bin/sh", std.mem.span(sh.path)); - try std.testing.expect(sh.argv[1] == null); - - // a name with no slash is searched for; whatever it resolves to, it is a - // bash and so carries --rcfile pointing at the rc the shells write - const bash = resolve("bash", &buf, &prompt_rcs); - try std.testing.expect(shellRc(std.mem.span(bash.path)) == .bash); - try std.testing.expectEqualStrings("--rcfile", std.mem.span(bash.argv[1].?)); - try std.testing.expectEqualStrings(prompt_rcs.bashPath().?, std.mem.span(bash.argv[2].?)); - - // nothing is installed under this name, so the fallback answers — and the - // fallback is a real executable, not the name that failed - const missing = resolve("zznosuchshell", &buf, &prompt_rcs); - try std.testing.expect(!std.mem.eql(u8, "zznosuchshell", std.mem.span(missing.path))); - try std.testing.expect(libc.access(missing.path, X_OK) == 0); - - // an absolute path that does not exist falls back too, rather than being - // handed to exec to fail on - const gone = resolve("/zz/no/such/shell", &buf, &prompt_rcs); - try std.testing.expect(libc.access(gone.path, X_OK) == 0); -} - -test "prompt rc owners have private complete files and clean them up" { - if (builtin.os.tag == .windows) return; - var a = PromptRcs.init(); - defer a.deinit(); - var b = PromptRcs.init(); - defer b.deinit(); - const a_bash = a.bashPath() orelse return error.TempCreateFailed; - const b_bash = b.bashPath() orelse return error.TempCreateFailed; - const a_fish = a.fishPath() orelse return error.TempCreateFailed; - try std.testing.expect(!std.mem.eql(u8, a_bash, b_bash)); - const fish_command = a.fishCommand() orelse return error.MissingFishCommand; - try std.testing.expectEqualStrings("source ", fish_command[0.."source ".len]); - try std.testing.expectEqualStrings(a_fish, fish_command["source ".len..]); - - var buf: [bash_rc.len]u8 = undefined; - const fd = libc.open(a_bash.ptr, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return error.OpenFailed; - defer _ = libc.close(fd); - var len: usize = 0; - while (len < buf.len) { - const n = libc.read(fd, buf[len..].ptr, buf.len - len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) break; - len += @intCast(n); - } - try std.testing.expectEqualStrings(bash_rc, buf[0..len]); - - var removed: [rc_path_capacity:0]u8 = @splat(0); - @memcpy(removed[0..a_bash.len], a_bash); - removed[a_bash.len] = 0; - a.deinit(); - try std.testing.expect(libc.access(&removed, 0) < 0); -} diff --git a/src/source_manifest.zig b/src/source_manifest.zig deleted file mode 100644 index c3bfde88..00000000 --- a/src/source_manifest.zig +++ /dev/null @@ -1,50 +0,0 @@ -//! The virtual filesystem: pardes's own source, embedded, as an ALLOWLIST. -//! -//! This is the default filesystem — what a host that implements no file method -//! reads and writes, and the only one the browser has ever had. It replaced a -//! build-time generator that embedded every tracked `.zig` file: 64 files and -//! 2.4 MB of generated Zig, rediscovered on every consumer build because the -//! tracked set can change without any known input changing. -//! -//! An allowlist instead, for the reason the generator's own comment gave away: -//! nothing needs all of them. What earns a place here is a file that explains -//! how pardes works to someone reading it inside pardes — the core, the host -//! seam, the keymap, the build. `src/pardes.zig` alone is 722 KB of the total, -//! and it is the one file worth that: it IS the program. -//! -//! Paths are as a user would type them, repo-root-relative, which is what -//! `look` resolves a click against. - -const limits = @import("limits.zig"); - -pub const Source = struct { path: []const u8, contents: []const u8 }; - -/// A slice, not an array: the P4 table is empty (see `limits.embedded_sources` -/// for why) and every consumer only ever iterates or takes `.len`. -pub const all: []const Source = if (limits.embedded_sources) &allowlist else &.{}; - -const allowlist = [_]Source{ - .{ .path = "build.zig", .contents = @embedFile("root-build.zig") }, - .{ .path = "build.zig.zon", .contents = @embedFile("root-build.zig.zon") }, - .{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") }, - .{ .path = "src/host.zig", .contents = @embedFile("host.zig") }, - .{ .path = "src/config.zig", .contents = @embedFile("config.zig") }, - .{ .path = "src/main.zig", .contents = @embedFile("main.zig") }, - .{ .path = "src/builtins.zig", .contents = @embedFile("builtins.zig") }, - .{ .path = "src/grammar_manifest.zig", .contents = @embedFile("grammar_manifest.zig") }, - .{ .path = "src/source_manifest.zig", .contents = @embedFile("source_manifest.zig") }, - .{ .path = "src/CHANGELOG.md", .contents = @embedFile("CHANGELOG.md") }, -}; - -/// The written-file override wins, then the allowlist. Callers own no memory -/// here: every byte is static or lives in the Fallback that answered. -pub fn find(path: []const u8) ?[]const u8 { - for (all) |s| if (eql(s.path, path)) return s.contents; - return null; -} - -fn eql(a: []const u8, b: []const u8) bool { - if (a.len != b.len) return false; - for (a, b) |x, y| if (x != y) return false; - return true; -} diff --git a/src/syntax.zig b/src/syntax.zig index f6e7cae2..77ef80d7 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -1,7 +1,3 @@ -//! Tree-sitter syntax highlighting: one style byte per content byte, filled by -//! running each grammar's highlights.scm query (slurped at build time into the -//! ts_queries options module). Grammar set is tiered: `zig`; `minimal` (c, cpp, -//! zig); and `full`, which adds ~24 languages lazily on first use. const std = @import("std"); const config = @import("pardes_config"); const tracy = @import("tracy.zig"); @@ -16,6 +12,8 @@ const full_grammars = config.syntax_full_grammars; const ts = if (enabled) @import("tree-sitter") else struct { pub const Language = opaque {}; pub const Query = opaque {}; + pub const Parser = opaque {}; + pub const QueryCursor = opaque {}; }; const ts_queries = if (enabled) @import("ts_queries") else struct {}; @@ -28,7 +26,8 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, - compiled_query: ?*ts.Query = null, + selected: ?Selected = null, + capture_styles: [256]u8 = undefined, }; fn grammarSelected(comptime g: grammar_manifest.Grammar) bool { @@ -46,21 +45,6 @@ fn specCount() comptime_int { } return count; } - -// Upstream's typst highlights.scm names its markup honestly — -// @markup.heading.*, @markup.bold, @markup.italic, @markup.raw.block — and -// `synFor` now understands that vocabulary, so headings, bold, italics and raw -// blocks paint on their own. What is left here is exactly the two captures we -// refuse to map globally: -// -// - upstream tags call callees @function/@function.method; mapping "function" -// in `synFor` would recolour every function call in every language. -// - upstream tags the code sigil "#" @operator, and mapping "operator" would -// likewise light up every +, -, == in the codebase. -// -// Both are worth colouring *in typst specifically*: the sigil in front of every -// #let/#if/#import/#call is the visual anchor of the code/markup split, and -// upstream leaves it uncoloured even though the keyword behind it is not. const typst_supplement = \\ \\(call item: (ident) @keyword) @@ -71,8 +55,69 @@ const typst_supplement = fn querySrc(comptime g: grammar_manifest.Grammar) []const u8 { const base = @field(ts_queries, g.name ++ "_highlights"); - if (comptime std.mem.eql(u8, g.name, "typst")) return base ++ typst_supplement; - return base; + const source = if (comptime std.mem.eql(u8, g.name, "typst")) base ++ typst_supplement else base; + return comptime colorQuery(source); +} + +fn colorQuery(comptime source: []const u8) []const u8 { + @setEvalBranchQuota(2_000_000); + var result: [source.len]u8 = undefined; + var written: usize = 0; + var at: usize = 0; + while (at < source.len) { + while (at < source.len) { + if (std.ascii.isWhitespace(source[at])) { + at += 1; + } else if (source[at] == ';') { + while (at < source.len and source[at] != '\n') at += 1; + } else break; + } + const begin = at; + var depth: usize = 0; + var colored = false; + var complete = false; + while (at < source.len) { + const byte = source[at]; + if (complete and (byte == '(' or byte == '[' or byte == '"')) break; + if (byte == ';') { + while (at < source.len and source[at] != '\n') at += 1; + continue; + } + if (byte == '"') { + at += 1; + while (at < source.len) : (at += 1) { + if (source[at] == '\\') { + at += 1; + } else if (source[at] == '"') { + at += 1; + break; + } + } + if (depth == 0) complete = true; + continue; + } + if (byte == '(' or byte == '[') depth += 1; + if (byte == ')' or byte == ']') { + depth -= 1; + if (depth == 0) complete = true; + } + if (byte == '@') { + const name = at + 1; + at = name; + while (at < source.len and (std.ascii.isAlphanumeric(source[at]) or + source[at] == '_' or source[at] == '.' or source[at] == '-')) at += 1; + colored = colored or synFor(source[name..at]) != .none; + continue; + } + at += 1; + } + if (colored) { + @memcpy(result[written..][0 .. at - begin], source[begin..at]); + written += at - begin; + } + } + const filtered = result[0..written].*; + return &filtered; } fn initSpecs() [specCount()]Spec { @@ -151,6 +196,7 @@ fn syntaxFree(ptr: ?*anyopaque) callconv(.c) void { pub fn start(gpa: std.mem.Allocator) void { if (comptime enabled) { std.debug.assert(!syntax_started); + stop(); syntax_allocator = gpa; syntax_started = true; ts_set_allocator(syntaxAlloc, syntaxCalloc, syntaxRealloc, syntaxFree); @@ -159,10 +205,13 @@ pub fn start(gpa: std.mem.Allocator) void { pub fn stop() void { if (comptime enabled) { - std.debug.assert(syntax_started); for (&specs) |*spec| { - if (spec.compiled_query) |query| query.destroy(); - spec.compiled_query = null; + if (spec.selected) |selected| { + selected.cursor.destroy(); + selected.parser.destroy(); + selected.query.destroy(); + } + spec.selected = null; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -170,17 +219,41 @@ pub fn stop() void { } } -const Selected = struct { name: []const u8, lang: *const ts.Language, query: *ts.Query }; +const Selected = struct { + name: []const u8, + lang: *const ts.Language, + query: *ts.Query, + parser: *ts.Parser, + cursor: *ts.QueryCursor, + capture_styles: []u8, +}; -// NOTE: don't lang.destroy() — the tree_sitter_*() languages are static -// singletons reused on every open; destroying one use-after-frees the next. fn ensure(spec: *Spec) !Selected { + if (spec.selected) |selected| return selected; const lang = spec.language(); - if (spec.compiled_query) |query| return .{ .name = spec.name, .lang = lang, .query = query }; var error_offset: u32 = 0; const query = try ts.Query.create(lang, spec.query_src, &error_offset); - spec.compiled_query = query; - return .{ .name = spec.name, .lang = lang, .query = query }; + errdefer query.destroy(); + if (query.captureCount() > spec.capture_styles.len) return error.TooManyCaptures; + const capture_styles = spec.capture_styles[0..query.captureCount()]; + for (capture_styles, 0..) |*style, id| { + const name = query.captureNameForId(@intCast(id)) orelse ""; + style.* = @intFromEnum(synFor(name)); + if (style.* == 0) query.disableCapture(name); + } + const parser = ts.Parser.create(); + errdefer parser.destroy(); + try parser.setLanguage(lang); + const selected: Selected = .{ + .name = spec.name, + .lang = lang, + .query = query, + .parser = parser, + .cursor = ts.QueryCursor.create(), + .capture_styles = capture_styles, + }; + spec.selected = selected; + return selected; } fn forExt(ext: []const u8) !?Selected { @@ -226,30 +299,20 @@ fn forLang(name: []const u8) !?Selected { } return null; } - -/// The caller owns the cursor: `ts_query_cursor_exec` fully resets its state, -/// so one cursor serves any number of trees, and the per-row pass would -/// otherwise create and destroy one — three allocations against the shared -/// tree-sitter arena — for every row of a results buffer. -fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize, cursor: *ts.QueryCursor) void { +fn runQuery(styles: []u8, sel: Selected, tree: *ts.Tree, base: usize) void { + const cursor = sel.cursor; cursor.exec(sel.query, tree.rootNode()); while (cursor.nextMatch()) |match| { for (match.captures) |cap| { - const syn = synFor(sel.query.captureNameForId(cap.index) orelse ""); - if (syn == .none) continue; + const style = sel.capture_styles[cap.index]; + if (style == 0) continue; const b = @min(base + cap.node.startByte(), styles.len); const end = @min(base + @as(usize, cap.node.endByte()), styles.len); - if (end > b) @memset(styles[b..end], @intFromEnum(syn)); + if (end > b) @memset(styles[b..end], style); } } } -// Queries compile on first use (`ensure`), never at startup. Pre-compiling the -// compact tier in Pardes.init cost EVERY boot ~120ms of ts_query__perform_analysis -// (55% of a Debug startup) to save ~40ms on the first .zig/.c/.cpp open — a pane -// of prose or a shell paid for a language it never opened. Grammar availability -// is unchanged; only the timing moved. - fn synFor(name: []const u8) Syn { for ([_]struct { []const u8, Syn }{ .{ "comment", .comment }, @@ -266,19 +329,6 @@ fn synFor(name: []const u8) Syn { .{ "title", .keyword }, .{ "uri", .string }, .{ "reference", .number }, - - // Markup grammars (markdown, typst) name prose constructs in their own - // vocabulary rather than the code vocabulary above, so none of the - // needles so far reach them. These are appended, and first-match-wins - // makes that strictly additive; the needles below were audited across - // all 27 shipped queries and occur only in the markdown and typst ones, - // so no other language is recoloured. - // - // The slot assignment is forced by the palette being four wide and by - // `synStyle` attaching the real BOLD attribute to exactly two of them, - // `keyword` and `comment`: headings take `keyword`, so bold spans have - // to land on `comment` to render actually bold. Nothing is left that - // renders italic, so emphasis can only get a colour shift (`number`). .{ "heading", .keyword }, .{ "strong", .comment }, .{ "bold", .comment }, @@ -291,75 +341,31 @@ fn synFor(name: []const u8) Syn { } return .none; } - -/// One Syn byte per content byte in [start, end). Caller frees. -pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { - const tz = tracy.zone(@src(), "highlightFileRange"); - defer tz.end(); +pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_raw: usize, end_raw: usize) ![]u8 { + const zone = tracy.zone(@src(), "highlightFileRange"); + defer zone.end(); if (!enabled) return &.{}; - const ext = std.fs.path.extension(path); - const selected = (forExt(ext) catch return &.{}) orelse return &.{}; - - const start_byte = @min(start_byte_raw, content.len); - const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); + const selected = (try forExt(std.fs.path.extension(path))) orelse return &.{}; + const start_byte = @min(start_raw, content.len); + const end_byte = @max(start_byte, @min(end_raw, content.len)); const source = content[start_byte..end_byte]; const styles = try gpa.alloc(u8, source.len); - errdefer gpa.free(styles); @memset(styles, 0); - - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(selected.lang) catch return styles; - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - paintWith(styles, source, selected, parser, cursor, true); + paint(styles, source, selected); return styles; } -/// Parse `source` and write its style bytes into `styles`, with a parser and a -/// query cursor the caller owns, so the per-row pass below can run a whole -/// results buffer through one of each. -/// -/// `inject` is off for a single row. Both injection passes build a SECOND -/// parser of their own — per fenced block, per `inline` node — which is -/// amortised over a document and absurd over one truncated grep row that -/// almost never contains a fenced block to begin with. -fn paintWith( - styles: []u8, - source: []const u8, - selected: Selected, - parser: *ts.Parser, - cursor: *ts.QueryCursor, - inject: bool, -) void { - const tree = parser.parseString(source, null) orelse return; +fn paint(styles: []u8, source: []const u8, selected: Selected) void { + const tree = selected.parser.parseString(source, null) orelse return; defer tree.destroy(); + runQuery(styles, selected, tree, 0); + if (std.mem.eql(u8, selected.name, "markdown")) { + inject(styles, source, tree.rootNode(), true); + } else if (std.mem.eql(u8, selected.name, "typst")) { + inject(styles, source, tree.rootNode(), false); + } +} - runQuery(styles, selected, tree, 0, cursor); - if (!inject) return; - - if (InjectSite.forGrammar(selected.name)) |site| injectCodeBlocks(styles, source, tree.rootNode(), site); - // Disjoint from the fenced-block pass above: `code_fence_content` is never - // an `inline` node, so the two never write the same byte. - if (std.mem.eql(u8, selected.name, "markdown")) injectMarkdownInline(styles, source, tree.rootNode()); -} - -/// A results buffer — every search, grep and language answer in this program — -/// coloured as the CODE it is quoting. -/// -/// The rows look like `src/look.zig:718:12-16 fn grepText(path: []const u8...`: -/// a location, a space, and a piece of some file. The location names the file, -/// the file names the grammar, and the rest of the row is a fragment of that -/// language — so a +Grep over Zig reads as Zig and one over Markdown does not -/// pretend to. `look.parsePathLine` decides what counts as a location, which is -/// the same primitive n/N walks these buffers with, so the two agree by -/// construction about which rows are locations. -/// -/// ONE PARSER AND ONE CURSOR for the whole buffer, and the buffer is coloured -/// once when it is filled rather than per visible window (file_pane -/// `refreshHighlights`) — the rows are independent, so a window pass buys no -/// fidelity and pays a burst of parses, cursors and first-time query compiles -/// on every scroll that outran the covered range. pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { const tz = tracy.zone(@src(), "highlightLocations"); defer tz.end(); @@ -370,19 +376,6 @@ pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byt const styles = try gpa.alloc(u8, source.len); errdefer gpa.free(styles); @memset(styles, 0); - - // Both are created on the first row that needs them and kept for the rest; - // `held` is the language the parser is currently set to. - var parser: ?*ts.Parser = null; - defer if (parser) |ptr| ptr.destroy(); - var cursor: ?*ts.QueryCursor = null; - defer if (cursor) |ptr| ptr.destroy(); - var held: ?Selected = null; - // Consecutive rows of a results buffer are overwhelmingly the same file, - // and `forExt` is a linear walk of 29 specs and their extension lists. One - // remembered answer collapses that to a string compare — including for the - // rows that match NOTHING (a jumplist `@p3:10`, a `.lock`, a `.txt`), - // which otherwise pay the whole failing scan every time. var memo_ext: []const u8 = "\x00"; var memo: ?Selected = null; var painted = false; @@ -398,100 +391,61 @@ pub fn highlightLocations(gpa: std.mem.Allocator, content: []const u8, start_byt memo = forExt(ext) catch null; } const selected = memo orelse continue; - if (parser == null) parser = ts.Parser.create(); - if (cursor == null) cursor = ts.QueryCursor.create(); - if (held == null or held.?.lang != selected.lang) { - // `held` is cleared FIRST: a failed `setLanguage` has already set - // the parser's language to null, so leaving `held` on the previous - // grammar makes every later row of it skip the call and parse - // against nothing — the rest of the buffer silently loses colour. - held = null; - parser.?.setLanguage(selected.lang) catch continue; - held = selected; - } - paintWith(styles[offset + code.at ..][0..code.text.len], code.text, selected, parser.?, cursor.?, false); + paint(styles[offset + code.at ..][0..code.text.len], code.text, selected); painted = true; } - // NOTHING TO PAINT IS NOTHING TO KEEP. `recolorSyntax` skips a pane whose - // highlights are empty, and every output buffer without locations in it — - // +Help, +Config, +Messages, +Errors — would otherwise hand the renderer a - // full-length run of zeroes and make it walk every visible grapheme, every - // frame, to paint nothing. if (!painted) { gpa.free(styles); return &.{}; } return styles; } - -/// The ` ` split of one results row, or null when the row is not -/// one. A row qualifies when its FIRST whitespace-delimited token is entirely a -/// look target — the whole token, so `see:` in prose does not count — and -/// something follows it. fn codeAfterLocation(line: []const u8) ?struct { path: []const u8, at: usize, text: []const u8 } { const token_end = std.mem.indexOfAny(u8, line, " \t") orelse return null; if (token_end == 0) return null; const token = line[0..token_end]; const target = look.parsePathLine(token); if (target.end != token.len) return null; - // A bare word is not a location: `main.zig` alone is a filename, but a - // results row is `main.zig:12:3`, and without that a prose line whose - // first word happens to end in `.md` would colour the rest of a sentence. if (target.at.line == 0) return null; - var at = token_end; - while (at < line.len and (line[at] == ' ' or line[at] == '\t')) at += 1; + const at = token_end + 1; if (at >= line.len) return null; return .{ .path = target.path, .at = at, .text = line[at..] }; } - -// Markdown fenced blocks and Typst raw blocks are the same construct — a -// language tag plus a literal payload — under different node shapes, so one -// walker drives both and only the (lang, content) extraction differs. -const InjectSite = enum { - markdown_fence, - typst_raw, - - fn forGrammar(name: []const u8) ?InjectSite { - if (std.mem.eql(u8, name, "markdown")) return .markdown_fence; - if (std.mem.eql(u8, name, "typst")) return .typst_raw; - return null; - } - - fn blockKind(self: InjectSite) []const u8 { - return switch (self) { - .markdown_fence => "fenced_code_block", - .typst_raw => "raw_blck", - }; - } - - /// null when the block carries no language tag (an untagged fence, or a - /// Typst raw block written without one) — nothing to inject, leave it alone. - fn parts(self: InjectSite, block: ts.Node) ?struct { lang: ts.Node, content: ts.Node } { - switch (self) { - .markdown_fence => { - const info = childOfKind(block, "info_string") orelse return null; - return .{ - .lang = childOfKind(info, "language") orelse return null, - .content = childOfKind(block, "code_fence_content") orelse return null, - }; - }, - .typst_raw => return .{ - .lang = block.childByFieldName("lang") orelse return null, - .content = childOfKind(block, "blob") orelse return null, - }, - } +fn inject(styles: []u8, source: []const u8, node: ts.Node, markdown: bool) void { + const kind = node.kind(); + if (markdown and std.mem.eql(u8, kind, "inline")) { + const begin: usize = node.startByte(); + const end: usize = node.endByte(); + if (begin >= end or end > source.len) return; + const text = source[begin..end]; + // Every colored inline capture requires one of these delimiters. + if (std.mem.indexOfAny(u8, text, "*_`[<\\\r\n") == null) return; + const selected = (forLang("markdown_inline") catch return) orelse return; + const tree = selected.parser.parseString(text, null) orelse return; + defer tree.destroy(); + runQuery(styles, selected, tree, begin); + return; } -}; - -fn injectCodeBlocks(styles: []u8, source: []const u8, node: ts.Node, site: InjectSite) void { - if (std.mem.eql(u8, node.kind(), site.blockKind())) { - highlightCodeBlock(styles, source, node, site); + if (std.mem.eql(u8, kind, if (markdown) "fenced_code_block" else "raw_blck")) { + const lang = if (markdown) blk: { + const info = childOfKind(node, "info_string") orelse return; + break :blk childOfKind(info, "language") orelse return; + } else node.childByFieldName("lang") orelse return; + const content = childOfKind(node, if (markdown) "code_fence_content" else "blob") orelse return; + const selected = (forLang(source[lang.startByte()..lang.endByte()]) catch return) orelse return; + const begin: usize = content.startByte(); + const end: usize = content.endByte(); + if (begin > end or end > source.len) return; + const tree = selected.parser.parseString(source[begin..end], null) orelse return; + defer tree.destroy(); + @memset(styles[begin..end], 0); + runQuery(styles, selected, tree, begin); return; } var i: u32 = 0; const count = node.childCount(); while (i < count) : (i += 1) { - if (node.child(i)) |c| injectCodeBlocks(styles, source, c, site); + if (node.child(i)) |child| inject(styles, source, child, markdown); } } @@ -506,79 +460,6 @@ fn childOfKind(node: ts.Node, kind: []const u8) ?ts.Node { return null; } -fn highlightCodeBlock(styles: []u8, source: []const u8, block: ts.Node, site: InjectSite) void { - const p = site.parts(block) orelse return; - const langtext = source[p.lang.startByte()..p.lang.endByte()]; - const sub_sel = (forLang(langtext) catch return) orelse return; - const cs: usize = p.content.startByte(); - const ce: usize = p.content.endByte(); - if (ce > source.len or cs > ce) return; - - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(sub_sel.lang) catch return; - const tree = parser.parseString(source[cs..ce], null) orelse return; - defer tree.destroy(); - // The outer grammar already painted these bytes (Typst blankets the whole - // raw block `string`), and runQuery only writes bytes it captures, so the - // outer colour would survive as a wash behind the injected code. The sub - // grammar owns the payload outright: clear it first. - @memset(styles[cs..ce], @intFromEnum(Syn.none)); - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - runQuery(styles, sub_sel, tree, cs, cursor); -} - -// tree-sitter-markdown is a split grammar: the block parser bottoms out at named -// `inline` nodes whose bytes it never looks inside, and emphasis / -// strong_emphasis / code_span exist only in the companion inline parser. So -// every `inline` node is re-parsed with `markdown_inline`, which is what -// upstream's injections.scm, helix and nvim all do (per node, uncombined — the -// stray block_continuation markers inside a multi-line paragraph's range are -// just plain text to the inline parser). -// -// The grammar is resolved and the parser built once per file, not once per node: -// only the parse is inherently per node. -fn injectMarkdownInline(styles: []u8, source: []const u8, root: ts.Node) void { - // Absent in builds below the `full` tier — nothing to inject, leave the - // block grammar's colours alone. - const sub_sel = (forLang("markdown_inline") catch return) orelse return; - const parser = ts.Parser.create(); - defer parser.destroy(); - parser.setLanguage(sub_sel.lang) catch return; - inlineNodes(styles, source, root, sub_sel, parser); -} - -fn inlineNodes(styles: []u8, source: []const u8, node: ts.Node, sel: Selected, parser: *ts.Parser) void { - if (std.mem.eql(u8, node.kind(), "inline")) { - highlightInline(styles, source, node, sel, parser); - return; // `inline` nodes never nest - } - var i: u32 = 0; - const count = node.childCount(); - while (i < count) : (i += 1) { - if (node.child(i)) |c| inlineNodes(styles, source, c, sel, parser); - } -} - -fn highlightInline(styles: []u8, source: []const u8, node: ts.Node, sel: Selected, parser: *ts.Parser) void { - const s: usize = node.startByte(); - const e: usize = node.endByte(); - if (s >= e or e > source.len) return; // an empty atx heading has a zero-length inline - const tree = parser.parseString(source[s..e], null) orelse return; - defer tree.destroy(); - // Deliberately NOT clearing the range first, unlike highlightCodeBlock: the - // block query already painted a heading's inline text `keyword`, and that is - // the colour the heading must keep wherever the inline pass captures - // nothing. Painting over instead of resetting is what makes *italic* inside - // a heading recolour while the rest of the heading stays heading-coloured. - const cursor = ts.QueryCursor.create(); - defer cursor.destroy(); - runQuery(styles, sel, tree, s, cursor); -} - -/// One Syn byte per byte of content[start, end) for unified diffs/patches. -/// Pure byte scan; independent of tree-sitter and the `enabled` flag. Caller frees. pub fn highlightDiff(gpa: std.mem.Allocator, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 { const start_byte = @min(start_byte_raw, content.len); const end_byte = @max(start_byte, @min(end_byte_raw, content.len)); @@ -610,14 +491,11 @@ fn diffLineSyn(line: []const u8) Syn { return .none; } -test "a results row is coloured by the file its location names" { +test "syntax a results row is coloured by the file its location names" { if (!enabled) return; start(std.testing.allocator); defer stop(); const gpa = std.testing.allocator; - - // Two rows quoting two languages, plus a row that is not a location and a - // location with nothing after it. const content = "src/a.zig:1:1 const S = struct {};\n" ++ "src/b.md:2:1 # heading\n" ++ @@ -626,53 +504,31 @@ test "a results row is coloured by the file its location names" { const styles = try highlightLocations(gpa, content, 0, content.len); defer gpa.free(styles); try std.testing.expectEqual(content.len, styles.len); - - // The LOCATION itself is left alone — it is not code, and colouring it as - // code is how a path starts looking like a keyword. for (styles[0.."src/a.zig:1:1".len]) |b| try std.testing.expectEqual(@as(u8, 0), b); - - // ...and `struct` in the Zig row is a keyword, which is only true if the - // grammar was chosen from `a.zig` rather than from the buffer's own name. - // - // `struct` and not `const`: tree-sitter-zig captures `const` as - // `@type.qualifier`, which `synFor` maps to nothing — a real property of - // the shipped query rather than of this pass, and the reason the first - // version of this test failed. const zig_kw = std.mem.indexOf(u8, content, "struct").?; try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw]); try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[zig_kw + 5]); - - // A row with no location contributes nothing... const prose = std.mem.indexOf(u8, content, "just some prose").?; for (styles[prose .. prose + 14]) |b| try std.testing.expectEqual(@as(u8, 0), b); - // ...and neither does a location with no code after it. const bare = std.mem.indexOf(u8, content, "src/c.zig").?; for (styles[bare..]) |b| try std.testing.expectEqual(@as(u8, 0), b); } -test "a buffer with no locations in it keeps no highlights at all" { +test "syntax a buffer with no locations in it keeps no highlights at all" { if (!enabled) return; start(std.testing.allocator); defer stop(); - // +Help, +Config, +Messages: prose. An all-zero run of styles is not the - // same as none — `recolorSyntax` skips a pane whose highlights are EMPTY, - // and returning a full-length run of zeroes made it walk every visible - // grapheme every frame to paint nothing. const content = "nothing has been said yet\n0: save: AccessDenied (x2)\n"; const styles = try highlightLocations(std.testing.allocator, content, 0, content.len); defer std.testing.allocator.free(styles); try std.testing.expectEqual(@as(usize, 0), styles.len); } -test "codeAfterLocation takes whole-token locations and nothing else" { - // A grep row: path, line, column range, then the quoted source. +test "syntax codeAfterLocation takes whole-token locations and nothing else" { const got = codeAfterLocation("src/x.zig:7:2-9 fn main() void {") orelse return error.ShouldBeALocation; try std.testing.expectEqualStrings("src/x.zig", got.path); try std.testing.expectEqualStrings("fn main() void {", got.text); - - // Not locations: a bare filename (no line), prose with a colon, a token - // that only PARTLY parses, and a location with nothing after it. try std.testing.expect(codeAfterLocation("main.zig some words") == null); try std.testing.expect(codeAfterLocation("note: this is prose") == null); try std.testing.expect(codeAfterLocation("src/x.zig:7:2x rest") == null); @@ -681,7 +537,7 @@ test "codeAfterLocation takes whole-token locations and nothing else" { try std.testing.expect(codeAfterLocation(" leading space") == null); } -test "tree-sitter allocator callbacks preserve and free exact allocations" { +test "syntax tree-sitter allocator callbacks preserve and free exact allocations" { syntax_allocator = std.testing.allocator; defer syntax_allocator = undefined; @@ -700,7 +556,7 @@ test "tree-sitter allocator callbacks preserve and free exact allocations" { live = null; } -test "default full grammar set highlights Typst source" { +test "syntax default full grammar set highlights Typst source" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -723,7 +579,7 @@ test "default full grammar set highlights Typst source" { try std.testing.expectEqual(Syn.keyword, @as(Syn, @enumFromInt(short_ext[keyword_at]))); } -test "Typst markup constructs paint and raw blocks inject their language" { +test "syntax Typst markup constructs paint and raw blocks inject their language" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -748,25 +604,14 @@ test "Typst markup constructs paint and raw blocks inject their language" { return @enumFromInt(s[std.mem.indexOf(u8, src, needle).? + offset]); } }.f; - - // marker and text of the heading both take the bold accent try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "= Heading", 0)); try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "Heading", 0)); - // *bold* is @markup.bold, which lands on the one slot that still renders - // with the real bold attribute now that headings own `keyword`. try std.testing.expectEqual(Syn.comment, synAt(styles, source, "bold", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "raw` inline", 0)); - // the callee and the code sigil in front of it try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "emit", 0)); try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "#emit", 0)); - - // fence and lang tag keep the literal colour of the raw block... try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 3)); - // ...while the blob is reset and re-painted by the injected zig grammar. Its - // `fn` and `99` prove the injection ran; `widget` proves the reset, since the - // zig query names it @function (Syn.none here) and without clearing the blob - // first the raw block's `string` would still be washing over it. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "fn widget", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "99", 0)); try std.testing.expectEqual(Syn.none, synAt(styles, source, "widget", 0)); @@ -778,7 +623,7 @@ test "Typst markup constructs paint and raw blocks inject their language" { try std.testing.expectEqual(Syn.string, synAt(styles, source, "\"arg\"", 0)); } -test "markdown highlights markup, injects inline spans and fenced code blocks" { +test "syntax markdown highlights markup, injects inline spans and fenced code blocks" { if (!enabled or !full_grammars) return; start(std.testing.allocator); defer stop(); @@ -799,31 +644,80 @@ test "markdown highlights markup, injects inline spans and fenced code blocks" { return @enumFromInt(s[std.mem.indexOf(u8, src, needle).? + offset]); } }.f; - - // The block grammar washes the whole heading `keyword`; the inline pass then - // paints the emphasis over it without resetting, so the heading keeps its - // colour everywhere the emphasis is not. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "Title", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "slant", 0)); - - // bold/italic/code-span live only in the inline grammar, so all three prove - // the inline injection ran. try std.testing.expectEqual(Syn.comment, synAt(styles, source, "stout", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "lean", 0)); try std.testing.expectEqual(Syn.string, synAt(styles, source, "snippet", 0)); - - // the fence is inside the block query's @text.literal wash... try std.testing.expectEqual(Syn.string, synAt(styles, source, "```zig", 0)); - // ...while the payload is cleared and re-painted by the injected zig - // grammar: `fn` and `77` prove the injection ran, and `gadget` proves the - // clear, since the zig query names it @function (Syn.none here) and without - // clearing first the fence's `string` would still be washing over it. try std.testing.expectEqual(Syn.keyword, synAt(styles, source, "fn gadget", 0)); try std.testing.expectEqual(Syn.number, synAt(styles, source, "77", 0)); try std.testing.expectEqual(Syn.none, synAt(styles, source, "gadget", 0)); } -test "highlightDiff colors unified diff lines by prefix" { +test "syntax inline fast path agrees with full Markdown query" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const selected = (try forLang("markdown_inline")).?; + const check = struct { + fn compare(sel: Selected, source: []const u8) !void { + const tree = sel.parser.parseString(source, null) orelse return error.ParseFailed; + defer tree.destroy(); + const expected = try std.testing.allocator.alloc(u8, source.len); + defer std.testing.allocator.free(expected); + const actual = try std.testing.allocator.alloc(u8, source.len); + defer std.testing.allocator.free(actual); + for ([_]Syn{ .none, .keyword }) |background| { + @memset(expected, @intFromEnum(background)); + @memset(actual, @intFromEnum(background)); + runQuery(expected, sel, tree, 0); + inject(actual, source, tree.rootNode(), true); + if (!std.mem.eql(u8, expected, actual)) std.debug.print("inline mismatch: {s}\n", .{source}); + try std.testing.expectEqualSlices(u8, expected, actual); + } + } + }.compare; + for ([_][]const u8{ + "", "plain prose", + "ação Ελληνικά 日本語 🙂", + "123 456", "tabs\tand spaces", + "'quoted' (parentheses) \"double quotes\"", "https://example.org a@b.org", + "& ", "~~struck~~ $formula$", + "*emphasis* __strong__", "**bold** _emphasis_", + "`code` and ``a`b``", "[text](target \"title\")", + "![description](image)", "[shortcut] [reference][label]", + "[[wiki|text]]", " ", + "text", "\\*escaped\\*", + "soft\nline", "hard \nline", + "hard\\\nline", "tab\t\nline", + "hard \r\nline", "hard \rline", + "**broken", "[broken](", + "`broken", + }) |source| try check(selected, source); + for (0..128) |byte| { + const char: u8 = @intCast(byte); + const source = [_]u8{ char, 'a', 'b', char, ' ', char, char, 'c', char, char }; + try check(selected, &source); + } +} + +test "syntax plain Markdown keeps block styles without starting the inline parser" { + if (!enabled or !full_grammars) return; + start(std.testing.allocator); + defer stop(); + const source = "# Heading\n\nPlain prose.\n\n indented code\n"; + const styles = try highlightFileRange(std.testing.allocator, "a.md", source, 0, source.len); + defer std.testing.allocator.free(styles); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[2]); + try std.testing.expectEqual(@intFromEnum(Syn.none), styles[std.mem.indexOf(u8, source, "Plain").?]); + try std.testing.expectEqual(@intFromEnum(Syn.string), styles[std.mem.indexOf(u8, source, "indented").?]); + for (specs) |spec| { + if (std.mem.eql(u8, spec.name, "markdown_inline")) try std.testing.expect(spec.selected == null); + } +} + +test "syntax highlightDiff colors unified diff lines by prefix" { const diff = "diff --git a/x b/x\n" ++ "--- a/x\n" ++ @@ -850,3 +744,98 @@ test "highlightDiff colors unified diff lines by prefix" { try std.testing.expectEqual(Syn.number, byteSyn(styles, diff, "-old line")); try std.testing.expectEqual(Syn.string, byteSyn(styles, diff, "+new line")); } + +test "syntax result fragments preserve source indentation and inline markup" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const fixtures = [_]struct { path: []const u8, source: []const u8 }{ + .{ .path = "a.zig", .source = " const number = 42; // note" }, + .{ .path = "a.md", .source = "# Heading *slant*" }, + .{ .path = "a.md", .source = "**bold** and `code`" }, + .{ .path = "a.md", .source = " # this is indented code" }, + .{ .path = "a.md", .source = "\t# tab-indented code" }, + .{ .path = "a.py", .source = " return \"hello\"" }, + }; + for (fixtures) |fixture| { + const expected = try highlightFileRange(std.testing.allocator, fixture.path, fixture.source, 0, fixture.source.len); + defer std.testing.allocator.free(expected); + const row = try std.fmt.allocPrint(std.testing.allocator, "{s}:12:3-9 {s}", .{ fixture.path, fixture.source }); + defer std.testing.allocator.free(row); + const actual = try highlightLocations(std.testing.allocator, row, 0, row.len); + defer std.testing.allocator.free(actual); + if (expected.len == 0) { + try std.testing.expectEqual(@as(usize, 0), actual.len); + continue; + } + const code_at = row.len - fixture.source.len; + try std.testing.expectEqualSlices(u8, expected, actual[code_at..]); + for (actual[0..code_at]) |style| try std.testing.expectEqual(@as(u8, 0), style); + } +} + +test "syntax query filtering preserves upstream colors" { + if (!enabled) return; + var allocator: std.heap.DebugAllocator(.{ .stack_trace_frames = 0, .safety = true }) = .init; + defer if (allocator.deinit() != .ok) @panic("leaked syntax query allocations"); + start(allocator.allocator()); + defer stop(); + const source = "// comment\n# Heading *inline*\nconst value = 42;\nif (true) { return \"quoted\"; }\n/* multi\nline */\n"; + inline for (grammar_manifest.all) |grammar| { + if (comptime grammarSelected(grammar)) { + const selected = (try forLang(grammar.name)).?; + const raw_source = @field(ts_queries, grammar.name ++ "_highlights") ++ + (if (comptime std.mem.eql(u8, grammar.name, "typst")) typst_supplement else ""); + var error_offset: u32 = 0; + const raw_query = try ts.Query.create(selected.lang, raw_source, &error_offset); + defer raw_query.destroy(); + var reference = selected; + reference.query = raw_query; + reference.capture_styles = try std.testing.allocator.alloc(u8, raw_query.captureCount()); + defer std.testing.allocator.free(reference.capture_styles); + for (reference.capture_styles, 0..) |*style, id| style.* = @intFromEnum(synFor(raw_query.captureNameForId(@intCast(id)) orelse "")); + const tree = selected.parser.parseString(source, null) orelse return error.ParseFailed; + defer tree.destroy(); + var expected: [source.len]u8 = @splat(0); + var actual: [source.len]u8 = @splat(0); + runQuery(&expected, reference, tree, 0); + runQuery(&actual, selected, tree, 0); + if (!std.mem.eql(u8, &expected, &actual)) std.debug.print("query mismatch: {s}\n", .{grammar.name}); + try std.testing.expectEqualSlices(u8, &expected, &actual); + } + } +} + +test "syntax Zig keyword captures cover both bytes beyond line ten thousand" { + if (!enabled) return; + start(std.testing.allocator); + defer stop(); + const code = "pub fn main() void {\n if (true) return;\n}\n"; + const source = try std.testing.allocator.alloc(u8, 10_001 + code.len); + defer std.testing.allocator.free(source); + @memset(source[0..10_001], '\n'); + @memcpy(source[10_001..], code); + const styles = try highlightFileRange(std.testing.allocator, "a.zig", source, 10_001, source.len); + defer std.testing.allocator.free(styles); + for ([_][]const u8{ "fn", "if" }) |keyword| { + const at = std.mem.indexOf(u8, code, keyword).?; + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[at]); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), styles[at + 1]); + } +} + +test "syntax allocator switching clears default-runtime caches" { + if (!enabled) return; + const source = "fn main() void {}"; + const initial = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + std.testing.allocator.free(initial); + start(std.testing.allocator); + const custom = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + std.testing.allocator.free(custom); + stop(); + const restored = try highlightFileRange(std.testing.allocator, "a.zig", source, 0, source.len); + defer std.testing.allocator.free(restored); + defer stop(); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[0]); + try std.testing.expectEqual(@intFromEnum(Syn.keyword), restored[1]); +} diff --git a/src/temp_file.zig b/src/temp_file.zig deleted file mode 100644 index a6174efd..00000000 --- a/src/temp_file.zig +++ /dev/null @@ -1,84 +0,0 @@ -//! Native-shell ownership of `New`'s one filesystem operation. -//! -//! The core asks for a temporary file by effect and receives only its path. -//! `mkstemp` creates and opens the name atomically with mode 0600, so there is -//! no name-then-open race and repeated requests cannot collide. A file the -//! core declines is unlinked here immediately; an adopted file becomes an -//! ordinary Pardes document and is deliberately left on disk when its pane is -//! closed, just like every other document (and so a dump remains restorable). -const std = @import("std"); -const libc = std.c; - -extern "c" fn mkstemp(template: [*:0]u8) c_int; -extern "c" fn lseek(fd: c_int, offset: libc.off_t, whence: c_int) libc.off_t; - -pub const Created = struct { - fd: c_int, - path: [:0]u8, - - /// The core copied the path and now owns the document. Close our creation - /// handle; Save and watching reopen/use the pathname through their normal - /// seams. - pub fn adopt(f: Created) void { - _ = libc.close(f.fd); - } - - /// Creation succeeded but the request became stale or the core could not - /// allocate a pane. Nothing user-visible owns this name, so remove it. - pub fn discard(f: Created) void { - _ = libc.close(f.fd); - _ = libc.unlink(f.path); - } -}; - -/// Create in the platform's conventional temporary directory. TMPDIR is a -/// shell concern (environment + filesystem), intentionally outside the core. -pub fn create(buf: *[4096:0]u8) ?Created { - const env = libc.getenv("TMPDIR"); - const dir = if (env) |p| std.mem.span(p) else "/tmp"; - return createIn(buf, if (dir.len > 0) dir else "/tmp"); -} - -/// Split out for a hermetic failure test and to keep template construction -/// independently checkable. The six Xs are consumed by mkstemp itself. -pub fn createIn(buf: *[4096:0]u8, dir_arg: []const u8) ?Created { - const dir = std.mem.trimEnd(u8, dir_arg, "/"); - const path = std.fmt.bufPrintSentinel( - buf, - "{s}{s}pardes-XXXXXX", - .{ if (dir.len == 0) "/" else dir, if (dir.len == 0) "" else "/" }, - 0, - ) catch return null; - const fd = mkstemp(path.ptr); - if (fd < 0) return null; - return .{ .fd = fd, .path = path }; -} - -test "mkstemp creates distinct empty files and rejected files are removable" { - var abuf: [4096:0]u8 = undefined; - var bbuf: [4096:0]u8 = undefined; - const a = createIn(&abuf, "/tmp") orelse return error.TempCreateFailed; - const b = createIn(&bbuf, "/tmp") orelse return error.TempCreateFailed; - defer b.discard(); - - try std.testing.expect(!std.mem.eql(u8, a.path, b.path)); - try std.testing.expectEqual(@as(libc.off_t, 0), lseek(a.fd, 0, 2)); // SEEK_END - try std.testing.expectEqual(@as(libc.off_t, 0), lseek(b.fd, 0, 2)); - - a.discard(); - try std.testing.expect(libc.unlink(a.path) < 0); // already removed -} - -test "an adopted tempfile remains named for the document" { - var buf: [4096:0]u8 = undefined; - const made = createIn(&buf, "/tmp") orelse return error.TempCreateFailed; - made.adopt(); - // Adoption closes only the creation handle. The ordinary file document - // keeps this path for Save, watch, Del and dump/restore. - try std.testing.expectEqual(@as(c_int, 0), libc.unlink(made.path)); -} - -test "mkstemp failure creates no candidate file" { - var buf: [4096:0]u8 = undefined; - try std.testing.expect(createIn(&buf, "/definitely/not/a/pardes/temp/directory") == null); -} diff --git a/src/term_pane.zig b/src/term_pane.zig deleted file mode 100644 index 17e50c88..00000000 --- a/src/term_pane.zig +++ /dev/null @@ -1,3525 +0,0 @@ -//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt -//! emulator — constructing and replaying the emulator, reading its grid back -//! out as text (for motions and for the body), translating its cell styles -//! into ours, handling the pty replies it hands back through a callback, and -//! keeping the edit-buffer undo snapshots that only exist because a terminal's -//! "content" is a live grid rather than a []u8. -//! -//! Shared modal edit semantics and the pane-wide screen/grid coordinate -//! invariants remain on Pane in pardes.zig. Terminal-only projection, history -//! snapshots, and cell styling live here, so the core does not need to know -//! how a live terminal becomes an editable text surface. -//! -//! ...and because it does not, this is the only CORE file that ever holds a -//! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and -//! image.zig's import exists solely to comptime-check a colour table against -//! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both -//! are backends, and neither is in the esp32p4 graph.) `pardes.terminal_panes` says -//! whether a build has an emulator at all; the two Pane slots and the -//! accessors under "the emulator, as the core is allowed to see it" are the -//! whole seam, and `!enabled` answers every one of them with the empty grid. -//! See pardes.terminal_panes for why the P4 firmware has none. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const Pardes = pardes.Pardes; -const Pane = pardes.Pane; -const Key = pardes.Key; -const EditText = pardes.EditText; -const modal = @import("modal.zig"); -const config = @import("config.zig"); -const dump = @import("dump.zig"); -const tracy = @import("tracy.zig"); // no-op unless -Dtracy names a checkout - -/// `pardes.terminal_panes`, re-exported so every gate in this file reads one -/// local name. When false the import below is a DEAD comptime branch, so -/// build.zig need not resolve the ghostty dependency at all. -pub const enabled = pardes.terminal_panes; -const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; - -/// EDIT-BUFFER BOUNDARIES REMEMBERED PER PANE. Snapshots copy the whole edit -/// buffer, so keep this tighter than files. -/// -/// A CAPACITY, not a presence: without an emulator `pane.ovl` is not a typed -/// overlay on a live grid, it is the pane's ENTIRE content (see `create` and -/// `restore` below), so undo on it matters more here, not less. But each entry -/// is a gpa copy of that content, and 64 of them is 2.5 KiB of `Pane` plus 64 -/// heap copies — on a board with a 384 KiB heap the ring would run out of -/// memory long before it ran out of slots. `pushHistory` evicts and frees the -/// oldest once full, so the shorter ring loses only the deepest undo steps. -pub const history_max = if (enabled) 64 else 8; - -/// The emulator and its VT parser as PANE FIELDS — the `PdfSlot` pattern from -/// pardes.zig, zero-sized where there are no terminal panes. Declared here -/// rather than there so the emulator's type never has to be named by the core. -pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; -pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; - -const GColor = ghostty_vt.color; - -/// The inputs which completely determine a filtered terminal palette. Theme -/// names and indices are intentionally absent: ThemeFile may replace a theme -/// in place under the same name, while equal colour values need no rebuild. -const FilterPaletteKey = struct { - bg: GColor.RGB, - fg: GColor.RGB, - base: [16]GColor.RGB, -}; - -/// Cached 256-colour projection of the current Pardes theme. Ghostty owns the -/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a -/// theme-derived RGB value while retaining the conventional dark-to-light -/// index orientation on light themes (`harmonious = false`). -/// -/// Zero-sized without an emulator — there are no ANSI cells to reproject, so -/// `Pardes.tty_filter_palette` costs the core nothing but keeps its `.{}`. -pub const FilterPalette = if (enabled) LivePalette else struct {}; - -const LivePalette = struct { - key: ?FilterPaletteKey = null, - colors: GColor.Palette = GColor.default, - - fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette { - const bg = asGhostRgb(theme.bg orelse theme.tag_bg); - const fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var base: [16]GColor.RGB = undefined; - if (theme.palette) |palette| { - for (&base, palette) |*dst, src| dst.* = asGhostRgb(src); - } else { - // A theme without an ANSI table still supplies every key. The - // first eight keep the usual semantic families; their bright - // partners use the same accents plus the theme's lighter chrome. - const synthesized = [16][3]u8{ - theme.bg orelse theme.tag_bg, - theme.kw, - theme.str, - theme.num, - theme.box, - theme.sel_bg, - theme.comment, - theme.fg orelse theme.tag_fg, - theme.lineno, - theme.kw, - theme.str, - theme.num, - theme.scroll_thumb, - theme.sel_fg, - theme.tag_fg, - theme.fg orelse theme.tag_fg, - }; - for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src); - } - - const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base }; - if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors; - - var seed = GColor.default; - for (base, 0..) |rgb, i| seed[i] = rgb; - self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false); - self.key = key; - return &self.colors; - } -}; - -fn asGhostRgb(rgb: [3]u8) GColor.RGB { - return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] }; -} - -fn asPardesColor(rgb: GColor.RGB) pardes.Color { - return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; -} - -/// The owned text standing in for `rows` live terminal rows, beginning at -/// absolute surface row `row`. The emulator grid remains untouched underneath. -pub const EditBuffer = struct { - row: i32 = 0, - rows: i32 = 1, - text: []u8 = &.{}, -}; - -/// One whole-state terminal edit boundary. Null means the pane has not yet -/// materialized an edit buffer; non-null snapshots own their text. -pub const Snapshot = struct { - ovl: ?EditBuffer, - cur_row: i32, - cur_col: i32, - vsel: pardes.CharSel, -}; - -/// Command bytes aimed at a shell whose prompt does not exist yet. The host -/// resolves the actual executable after the core has already queued `.spawn`, -/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration -/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are -/// opened immediately by `shellSpawned` and let the pty buffer input until the -/// child reads it. -/// -/// This is pane state, not a Pardes-wide job table. A reused pane slot can -/// therefore never inherit a command intended for the shell it replaced. -pub const PendingCommand = struct { - bytes: []u8 = &.{}, - wait: enum { none, spawn, input } = .none, -}; - -/// A terminal constructed by `newShell` cannot safely receive a command until -/// the native host has at least completed forkpty. Dump-replay terminals do -/// not call this: they are dead grids, not half-spawned children. -pub fn armShellSpawn(pane: *Pane) void { - // With no emulator there is no fork to wait on and no OSC 133 that could - // ever arrive, so the gate stays open: `queuePendingCommand` declines and - // the command leaves as an ordinary write, rather than waiting forever. - if (comptime !enabled) return; - std.debug.assert(pane.pending_command.bytes.len == 0); - pane.pending_command.wait = .spawn; -} - -/// Own `command` until this pane's new child can accept it. False means the -/// gate is already open and the caller should emit in the ordinary way. -/// Multiple gestures before the prompt appears retain their byte order in one -/// flat allocation; each command gets exactly the CR execute normally emits. -pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { - if (pane.pending_command.wait == .none) return false; - const old_len = pane.pending_command.bytes.len; - const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); - const bytes = if (old_len == 0) - try pane.gpa.alloc(u8, new_len) - else - try pane.gpa.realloc(pane.pending_command.bytes, new_len); - @memcpy(bytes[old_len..][0..command.len], command); - bytes[new_len - 1] = '\r'; - pane.pending_command.bytes = bytes; - // An explicit command replaces the automatic greeting. Otherwise both - // would be released by the same first prompt and `ls` would follow what - // the user actually asked to run. - pane.greet = false; - return true; -} - -/// The host successfully forked `pane`. `prompt_marks` describes the argv it -/// ACTUALLY used, not the configured shell name: a staged-rc failure or an -/// unsupported family is an honest unmarked shell and must not wait forever -/// for an OSC sequence it cannot produce. -pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { - const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; - if (prompt_marks) { - pane.pending_command.wait = .input; - releasePendingCommand(p, id, pane, false); - } else { - // There is no semantic event on which an automatic greeting can be - // safely based. Explicit commands still release below (the pty owns - // their buffering); silently omit the cosmetic `ls` rather than race - // an unknown shell's startup and possibly type into its rc program. - pane.greet = false; - releasePendingCommand(p, id, pane, true); - } -} - -/// Called from the ordinary sync after terminal output has been parsed. The -/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting -/// share the exact same definition of "readline owns input". -pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { - if (pane.pending_command.wait == .input) - releasePendingCommand(p, id, pane, promptInputReady(pane)); -} - -fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { - if (!ready) return; - const bytes = pane.pending_command.bytes; - pane.pending_command = .{}; - if (bytes.len > 0) { - p.emitWrite(id, bytes); - pane.gpa.free(bytes); - } -} - -pub fn deinitPendingCommand(pane: *Pane) void { - if (pane.pending_command.bytes.len > 0) - pane.gpa.free(pane.pending_command.bytes); - pane.pending_command = .{}; -} - -/// The emulator stores an Io value for optional kitty-image work. The browser -/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend -/// merely to construct a replay-only terminal. -pub fn terminalIo() std.Io { - return if (comptime !pardes.hosted) - std.Io.failing - else - std.Io.Threaded.global_single_threaded.io(); -} - -// ---- the emulator, as the core is allowed to see it ---- -// -// Every question pardes.zig used to answer by walking `pane.vt.screens.active` -// for itself, named. That is the boundary this file's header always claimed, -// and naming them is what lets a build with no emulator answer ALL of them at -// comptime with the empty grid, instead of scattering one platform test -// through the core's scroll, cursor, mouse, resize and render paths. - -/// The three numbers ghostty's scrollbar reports; all zero without an emulator. -pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 }; - -pub fn scrollbar(pane: *const Pane) Scrollbar { - if (comptime !enabled) return .{}; - const sb = pane.vt.screens.active.pages.scrollbar(); - return .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; -} - -/// The emulator's viewport offset, in SHELL rows: the top of what it shows. -pub fn gridOffset(pane: *const Pane) i32 { - return @intCast(scrollbar(pane).offset); -} - -/// Where the emulator itself puts the cursor, in viewport cells — the origin -/// without one, which is where an empty pane's cursor belongs anyway. -pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 }; - -pub fn gridCursor(pane: *const Pane) GridCursor { - if (comptime !enabled) return .{}; - const cur = pane.vt.screens.active.cursor; - return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; -} - -/// Move the emulator's viewport by `delta` shell rows (negative scrolls back). -pub fn scrollGrid(pane: *Pane, delta: i32) void { - if (comptime !enabled) return; - pane.vt.screens.active.scroll(.{ .delta_row = delta }); -} - -/// Snap the viewport back onto live output. -pub fn followOutput(pane: *Pane) void { - if (comptime !enabled) return; - pane.vt.screens.active.scroll(.active); -} - -/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping -/// a scrollback; the next resize retries with the same numbers. -pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void { - if (comptime !enabled) return; - pane.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {}; -} - -/// DECSET 2004: the program wants its pastes bracketed. -pub fn bracketedPaste(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.modes.get(.bracketed_paste); -} - -/// The program tracks the mouse itself, so a click in its body is its event. -pub fn reportsMouse(pane: *const Pane) bool { - if (comptime !enabled) return false; - const m = &pane.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); -} - -/// ...and wants them in SGR (1006) rather than the legacy X10 bytes. -pub fn mouseFormatSgr(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.modes.get(.mouse_format_sgr); -} - -/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out. -pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 { - if (comptime !enabled) return &.{}; - return pane.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} }); -} - -/// Release the emulator's heap. The Pane allocation itself is the core's. -pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { - if (comptime !enabled) return; - pane.stream.deinit(); - pane.vt.deinit(gpa); -} - -/// Allocate the live emulator half of a terminal pane. Slot ownership, serial -/// assignment, and spawn effects remain core lifecycle invariants. -pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - errdefer gpa.destroy(pane); - if (comptime !enabled) { - // No emulator: the pane is a plain text surface whose whole content is - // its edit buffer. `tty_filter` stays off — there are no ANSI cells to - // reproject and `recolorAnsi` is compiled out entirely. - pane.* = .{ .vt = {}, .stream = {}, .gpa = gpa, .cols = cols, .rows = rows }; - return pane; - } - pane.* = .{ - .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ - .cols = cols, - .rows = rows, - .max_scrollback = 16 * 1024 * 1024, - }), - .stream = undefined, - .gpa = gpa, - .cols = cols, - .rows = rows, - // Real terminals start theme-keyed. Document panes use the separate - // 1x1 stub constructor and retain Pane's inert false default. - .tty_filter = true, - }; - pane.stream = pane.vt.vtStream(); - // Answer terminal queries (DSR/DA/kitty) back into the pty, else - // crossterm apps (nushell, helix, fzf) block on the reply forever. - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; - return pane; -} - -/// A doc pane (file/image/PDF): no pty and no spawn, and a stub 1x1 emulator -/// only because the shared pane machinery touches its allocator-owned bits. -/// Slot registration stays with the core, as for `create`. -pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { - const pane = try gpa.create(Pane); - errdefer gpa.destroy(pane); - pane.* = .{ - .vt = if (comptime enabled) - try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = 1, .rows = 1 }) - else {}, - .stream = undefined, - .gpa = gpa, - .cols = cols, - .rows = rows, - }; - if (comptime enabled) pane.stream = pane.vt.vtStream(); - return pane; -} - -/// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy -/// stay with the core; raw VT replay and viewport restoration belong here. -pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { - const terminal = src.terminal.?; - if (comptime !enabled) { - // Nothing to replay the recorded VT bytes INTO. The dump also carries - // the rendered text of that grid, so it becomes the pane's edit buffer - // — the one content a build with no emulator can show at all. - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - errdefer p.gpa.destroy(pane); - if (terminal.stream.len > 0) - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; - return pane; - } - const bytes = if (terminal.stream_b64.len > 0) - try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) - else - &.{}; - const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (bytes.len > 0) { - ingest(pane, bytes); - followOutput(pane); - if (src.scroll > 0) - scrollGrid(pane, -@as(i32, @intCast(src.scroll))); - } - return pane; -} - -/// Feed the emulator and retain the bounded suffix a dump can replay. Live -/// output and restoration share this byte path, then apply different views. -fn ingest(pane: *Pane, bytes: []const u8) void { - if (bytes.len >= pane.tty_stream.len) { - const kept = bytes[bytes.len - pane.tty_stream.len ..]; - @memcpy(pane.tty_stream[0..], kept); - pane.tty_stream_head = 0; - pane.tty_stream_len = pane.tty_stream.len; - } else { - const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len); - pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len; - pane.tty_stream_len -= overflow; - const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len; - const first = @min(bytes.len, pane.tty_stream.len - tail); - @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]); - @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]); - pane.tty_stream_len += bytes.len; - } - pane.stream.nextSlice(bytes); -} - -/// Return the replay ring in chronological order. Wrapped records are copied -/// into `allocator`; contiguous records remain a borrowed slice of the pane. -fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { - if (pane.tty_stream_len == 0) return &.{}; - if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len) - return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len]; - const out = try allocator.alloc(u8, pane.tty_stream_len); - const first = pane.tty_stream.len - pane.tty_stream_head; - @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]); - @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]); - return out; -} - -/// Record and parse one live pty read, invalidate its motion surface, and -/// follow it only when the body (possibly parked under a tag edit) is raw. -pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { - // There are no pty reads at all without an emulator to parse them into. - if (comptime !enabled) return; - ingest(pane, bytes); - p.shell_rows.markStale(pane); - const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (body_mode == .tty) followOutput(pane); -} - -/// True only after OSC 133 B ended the prompt and handed the cursor to shell -/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt` -/// phase; that is right for navigation but too early to inject a greeting — -/// readline may not own echo yet and would leave `ls` on an unmarked row. -pub fn promptInputReady(pane: *const Pane) bool { - if (comptime !enabled) return false; - return pane.vt.screens.active_key != .alternate and - pane.vt.screens.active.cursor.semantic_content == .input; -} - -test "fresh-shell greeting waits for OSC 133 B input phase" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer p.deinit(); - const pane = p.panes[0].?; - while (p.nextEffect()) |_| {} // initial spawn - - p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedBeforeOutput, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedBeforePrompt, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); - try std.testing.expect(!promptInputReady(pane)); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.GreetedDuringPrompt, - else => {}, - }; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); - try std.testing.expect(promptInputReady(pane)); - var greeted = false; - while (p.nextEffect()) |effect| switch (effect) { - .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), - else => {}, - }; - try std.testing.expect(greeted); - try std.testing.expect(!pane.greet); -} - -test "fresh-shell commands preserve order and wait for OSC 133 B" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet - - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); - try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedBeforeFork, - else => {}, - }; - - p.acknowledgeShell(0, "/bin/bash", true); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedBeforePrompt, - else => {}, - }; - p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.CommandEscapedDuringPrompt, - else => {}, - }; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); - var sent: [64]u8 = undefined; - var sent_len: usize = 0; - while (p.nextEffect()) |effect| switch (effect) { - .write => |write| { - const bytes = write.bytes.slice(); - @memcpy(sent[sent_len..][0..bytes.len], bytes); - sent_len += bytes.len; - }, - else => {}, - }; - try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); - try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); -} - -test "unmarked fresh shells omit the automatic greeting" { - if (pardes.platform == .web) return; - const p = try Pardes.init(std.testing.allocator, .{}); - defer p.deinit(); - const pane = p.panes[0].?; - while (p.nextEffect()) |_| {} - try std.testing.expect(pane.greet); - - p.acknowledgeShell(0, "/bin/sh", false); - try std.testing.expect(!pane.greet); - try std.testing.expectEqual(.none, pane.pending_command.wait); - p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); - while (p.nextEffect()) |effect| switch (effect) { - .write => return error.UnmarkedGreetingEscaped, - else => {}, - }; -} - -/// Encode one key for the program that owns a raw terminal and queue its pty -/// write. Global chords and mode routing have already been handled by core. -pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { - var control: [1]u8 = undefined; - const bytes: ?[]const u8 = blk: { - if (key.ctrl) { - if (key.cp >= 'a' and key.cp <= 'z') { - control[0] = @intCast(key.cp - 0x60); - break :blk control[0..1]; - } - // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. - if (key.cp >= '@' and key.cp <= '_') { - control[0] = @intCast(key.cp - 0x40); - break :blk control[0..1]; - } - } - if (key.text.len > 0) break :blk key.text; - break :blk switch (key.cp) { - Key.enter => "\r", - Key.backspace => "\x7f", - Key.tab => "\t", - Key.escape => "\x1b", - Key.up => "\x1b[A", - Key.down => "\x1b[B", - Key.right => "\x1b[C", - Key.left => "\x1b[D", - Key.delete => "\x1b[3~", - else => null, - }; - }; - if (bytes) |encoded| - p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); -} - -/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when -/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode, -/// so translate its column through the hidden prompt before asking ghostty for -/// the arrow-key movement the child understands. -pub fn enterTty(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - // Only the PROMPT HANDOFF needs the emulator; the mode switch below is - // plain pane state, so a build without one still has a raw mode — it just - // has no prompt to translate a pinned cursor back onto. - if (comptime enabled) if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: { - const screen = pane.vt.screens.active; - const goff: i32 = @intCast(screen.pages.scrollbar().offset); - const vp_row = pane.gridRow(pane.cur_row) - goff; - if (vp_row < 0) break :handoff; - - var grid_col: i32 = @max(0, pane.cur_col); - if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| { - if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) { - .cut => |cols| grid_col += @intCast(cols), - .keep, .blank => {}, - }; - } - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) }, - }) orelse break :handoff; - const cursor_pin = screen.cursor.page_pin.*; - var prompts = cursor_pin.promptIterator(.left_up, null); - const prompt_pin = prompts.next() orelse break :handoff; - if (click_pin.before(prompt_pin)) break :handoff; - const moves = screen.promptClickMove(click_pin); - for (0..moves.left) |_| p.emitWrite(id, "\x1b[D"); - for (0..moves.right) |_| p.emitWrite(id, "\x1b[C"); - }; - - pane.mode = .tty; - pane.msel.active = false; - pane.vsel.active = false; - pane.nsel = 0; - // A pinned row scrolls away. Raw mode must follow the program's live - // cursor, and Last must not restore a stale modal spot on the way back. - pane.cur_pinned = false; - pane.select = false; - pane.append_at = null; - pane.sticky_col = -1; - pane.pending = 0; -} - -test "raw terminal keys encode text controls and special sequences" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - const Case = struct { key: Key, expected: ?[]const u8 }; - const cases = [_]Case{ - .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, - .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, - .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, - .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, - .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, - .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, - .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, - .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, - .{ .key = .{ .cp = Key.home }, .expected = null }, - }; - for (cases) |case| { - forwardKey(p, 0, case.key); - const expected = case.expected orelse { - try std.testing.expect(p.nextEffect() == null); - continue; - }; - const effect = p.nextEffect() orelse return error.MissingWriteEffect; - switch (effect) { - .write => |write| { - try std.testing.expectEqual(@as(u8, 0), write.pane); - try std.testing.expectEqualStrings(expected, write.bytes.slice()); - }, - else => return error.UnexpectedEffect, - } - try std.testing.expect(p.nextEffect() == null); - } -} - -/// The memo behind `shellRows`. ONE entry for the editor, because the motion -/// surface is built for the pane the cursor is in and a second pane asking -/// would only double a multi-megabyte buffer for a slot it is about to lose -/// again. gpa-owned rather than scratch-arena: the whole point is to outlive -/// the update that built it. -/// -/// LIFETIME, the part that would rot silently: `rows` is handed out to -/// callers, so the buffers are freed in exactly two places — `sweep`, at the -/// TOP of an update before any handler can be holding them, and `reset` when -/// the editor goes away. Everything that notices the entry has gone bad -/// (output arrived, the grid reflowed, the pane died, another pane wants the -/// slot) only marks it `stale`; nothing frees mid-update. That is the same -/// guarantee the scratch arena gave, spelled out. -pub const RowsCache = struct { - /// whose grid this describes; null = the slot is free - pane: ?*const Pane = null, - /// the rows joined by '\n' — `flatSurface` hands this back verbatim - /// instead of rebuilding the join on every keystroke - text: []const u8 = &.{}, - /// slices INTO `text`, absolute grid rows from 0 - rows: [][]const u8 = &.{}, - /// `text` is a prefix of this: blanking a prompt row shortens the join, - /// and the slack is not worth a second allocation to reclaim - text_alloc: []u8 = &.{}, - stale: bool = false, - - pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.text_alloc.len > 0) gpa.free(c.text_alloc); - if (c.rows.len > 0) gpa.free(c.rows); - c.* = .{}; - } - - /// Free a stale entry. Called at the top of `update`, and nowhere else. - pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void { - if (c.stale) c.reset(gpa); - } - - /// `pane`'s grid moved: the entry no longer describes it. - pub fn markStale(c: *RowsCache, pane: *const Pane) void { - if (c.pane == pane) c.stale = true; - } - - /// `pane` is being destroyed. Drop the pointer now — a freed pane's - /// address can come back from the allocator as a different pane, and an - /// entry still naming it would answer for the wrong grid — but leave the - /// buffers to the next sweep, as ever. - pub fn dropPane(c: *RowsCache, pane: *const Pane) void { - if (c.pane != pane) return; - c.pane = null; - c.stale = true; - } -}; - -const Rows = struct { - text_alloc: []u8, - text: []const u8, - rows: [][]const u8, -}; - -/// The motion surface of a pane with no emulator behind it: exactly the one -/// blank row `buildRows` retains from a real grid, so surface row 0 exists and -/// every motion, edit and undo path measures the same thing it always did. -const empty_grid = [1][]const u8{""}; - -/// What LEAVING raw tty mode does to one prompt row, decided from its cells -/// alone. See config.tty_blank for why any of this happens. -const PromptCut = union(enum) { - /// show the row exactly as ghostty dumped it - keep, - /// show nothing at all - blank, - /// drop this many leading COLUMNS — the prompt — and keep the rest, which - /// is what was typed at it - cut: usize, -}; - -/// The prompt and the command typed at it share a grid row, and OSC 133 marks -/// them apart CELL by cell (`Cell.semantic_content` is output / input / -/// prompt). The row flag every caller tests first is only ghostty's "some cell -/// in here is a prompt cell" index; taking the row on that flag alone is what -/// used to throw the command away with the prompt. -fn promptCut(pin: ghostty_vt.Pin) PromptCut { - if (config.tty_blank == .prompt_and_input) return .blank; - const cells = pin.cells(.all); - var cols: usize = 0; - while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1; - // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or - // a repaint that has moved on. Nothing here is the prompt, so hide nothing. - if (cols == 0) return .keep; - // ...and all prompt, nothing typed yet: the row is chrome end to end. - if (cols >= cells.len) return .blank; - return .{ .cut = cols }; -} - -/// That decision applied to `raw`, the line ghostty dumped for `pin`'s row. -/// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command -/// starts at column 0 with no run of blanks in front of it where the prompt -/// used to be, and there is nothing to allocate or copy anywhere. -/// -/// Walking the dump rather than rebuilding the row out of cells keeps ghostty -/// the single authority on how a cell spells itself — wide glyphs, combining -/// marks and all. One non-spacer cell is one dumped grapheme, and that is what -/// makes the cell walk and the byte walk stay in step. -fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 { - const cols = switch (promptCut(pin)) { - .keep => return raw, - .blank => return "", - .cut => |n| n, - }; - const cells = pin.cells(.all); - var at: usize = 0; - var col: usize = 0; - while (col < cols and at < raw.len) { - const cell = &cells[col]; - // Step the dump by exactly what THIS CELL contributed to it. The - // tempting walk — one `modal.nextGrapheme` per cell — assumes the two - // sides agree on where a cluster ends, and they do not: ghostty keeps a - // ZWJ family emoji in three cells and spells each one separately, while - // pardes' iterator joins the whole sequence into one grapheme. That walk - // then consumed three graphemes for one cell's worth of bytes and ate - // the first characters of what was typed at the prompt. - at = @min(raw.len, at + dumpedBytes(pin, cell)); - // the tail cell of a wide glyph spells nothing of its own - col += if (cell.wide == .wide) @as(usize, 2) else 1; - } - return std.mem.trimEnd(u8, raw[at..], " \t"); -} - -/// How many bytes `cell` contributed to `pin`'s dumped row. -/// -/// `ScreenFormatter` writes a cell's codepoint followed by the grapheme -/// codepoints stored with it, and writes NOTHING for either spacer, so this is -/// the dump's own arithmetic rather than a guess about clustering. -fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize { - switch (cell.wide) { - .spacer_head, .spacer_tail => return 0, - .narrow, .wide => {}, - } - var n: usize = switch (cell.content_tag) { - .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength( - cell.codepoint(), - ) catch 1, - // A cell carrying only a colour still spells one blank in the dump. - else => 1, - }; - if (cell.content_tag == .codepoint_grapheme) { - if (pin.grapheme(cell)) |extra| for (extra) |cp| { - n += std.unicode.utf8CodepointSequenceLength(cp) catch 1; - }; - } - return n; -} - -/// A terminal's shell rows as the surface sees them: the WHOLE -/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows -/// from 0. The raw material the motion surface is composed from — the -/// edit buffer is NOT applied here, so it is also what seeding the buffer -/// reads. -/// ghostty's dump trims the grid's trailing blank rows; ONE of them is -/// kept back, the row the cursor sits on below the last line of output. -/// That row is a file's final newline: without it the surface would have -/// one line fewer than the same text in a document, and every motion and -/// linewise edit at the bottom would diverge. -/// -/// Building it is O(scrollback) — a dump of the whole history — and a -/// keystroke asks for it once or twice, so the result is memoized against the -/// pane until its grid changes. A pane sitting on 16 MiB of agent transcript -/// paid that dump per press of `j` before the cache; now it pays it once per -/// chunk of output. -pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - // With no emulator there is no history to dump, and no cache to keep it - // in: one empty row, which is the same row `buildRows` keeps back from - // ghostty's trimmed dump — a file's final newline. Everything above the - // grid (the edit overlay, its undo stacks, every motion) works unchanged - // over it, so a pane on the board is an ordinary scratch buffer. - if (comptime !enabled) return &empty_grid; - const c = &p.shell_rows; - if (!c.stale and c.pane == pane) return c.rows; - if (c.pane != null) { - // Another pane holds the slot. Take it for the NEXT update (the sweep - // frees what is there) and answer this one from scratch: whoever owns - // the live entry may still be holding the rows it handed out. - c.stale = true; - return (try buildRows(p.scratch.allocator(), p, pane)).rows; - } - const built = try buildRows(p.gpa, p, pane); - c.* = .{ - .pane = pane, - .text = built.text, - .rows = built.rows, - .text_alloc = built.text_alloc, - }; - return c.rows; -} - -/// The full modal motion surface: shell history with the live edit overlay -/// spliced into the rows it covers. -pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { - const rows = try shellRows(p, pane); - if (pane.ovl == null) return rows; - var last = rows.len; - if (pane.ovl) |overlay| - last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows)))); - var count = last; - if (pane.ovl) |overlay| { - if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last) - count = count - @min( - @as(usize, @intCast(overlay.rows)), - last - @as(usize, @intCast(overlay.row)), - ) + @max(1, modal.lineCount(overlay.text)); - } - const lines = try p.scratch.allocator().alloc([]const u8, count); - var n: usize = 0; - var grid_row: usize = 0; - while (grid_row < last) : (grid_row += 1) { - if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) { - var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n'); - while (overlay_lines.next()) |line| : (n += 1) lines[n] = line; - grid_row += @intCast(overlay.rows - 1); - continue; - }; - lines[n] = if (grid_row < rows.len) rows[grid_row] else ""; - n += 1; - } - return lines[0..n]; -} - -/// Flatten `cursorLines` without rebuilding the common cached/no-overlay -/// case. Scratch-owned when a join is required. -pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 { - const cache = &p.shell_rows; - if (!cache.stale and cache.pane == pane and - lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text; - var total: usize = if (lines.len > 0) lines.len - 1 else 0; - for (lines) |line| total += line.len; - const text = try p.scratch.allocator().alloc(u8, total); - var at: usize = 0; - for (lines, 0..) |line, i| { - if (i > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..line.len], line); - at += line.len; - } - return text; -} - -/// Materialize or extend the terminal edit overlay with one exact allocation. -/// Row slices are scratch-owned/borrowed; only the joined text is installed. -pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { - const want_lo = @max(0, @min(lo, hi)); - const want_hi = @max(want_lo, @max(lo, hi)); - const fresh = pane.ovl == null; - const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; - const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); - const up = old.row - want_lo; - const down = want_hi - (old.row + lines - 1); - const extending = fresh or up > 0 or down > 0; - var row0 = old.row; - var covered = old.rows; - var text = old.text; - var owned = false; - if (extending) { - const rows = shellRows(p, pane) catch return null; - row0 = old.row - @max(0, up); - covered = old.rows + @max(0, up) + @max(0, down); - const up_len: usize = @intCast(@max(0, up)); - const down_len: usize = @intCast(@max(0, down)); - const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null; - for (parts[0..up_len], 0..) |*part, i| { - const src = @as(usize, @intCast(row0)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - const middle: usize = @intCast(old.row); - parts[up_len] = if (fresh) - (if (middle < rows.len) rows[middle] else "") - else - old.text; - for (parts[up_len + 1 ..], 0..) |*part, i| { - const src = @as(usize, @intCast(old.row + old.rows)) + i; - part.* = if (src < rows.len) rows[src] else ""; - } - text = std.mem.join(p.gpa, "\n", parts) catch return null; - owned = true; - } - - const row: usize = @intCast(@max(0, want_lo - row0)); - const line_len: i32 = @intCast(modal.lineSlice(text, row).len); - if (col > line_len) { - const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch { - if (owned) p.gpa.free(text); - return null; - }; - @memset(spaces, ' '); - const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch { - if (owned) p.gpa.free(text); - return null; - }; - if (owned) p.gpa.free(text); - text = padded; - owned = true; - } - if (owned) { - if (pane.ovl) |overlay| p.gpa.free(overlay.text); - pane.ovl = .{ .row = row0, .rows = covered, .text = text }; - } - return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row }; -} - -/// Consume a rewritten overlay, freeing the terminal edit text it replaces. -pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); - p.gpa.free(overlay.text); - overlay.text = new; -} - -/// Serialize terminal-only state; the core supplies shared pane metadata. -pub fn dumpPane( - pane: *Pane, - arena: std.mem.Allocator, - tag: []const u8, - body: []const u8, - scroll: usize, -) !dump.Pane { - if (comptime !enabled) { - // A pane with no emulator has no grid to serialize and no VT bytes to - // record — its edit buffer IS its content, so that is what the dump - // carries, and `restore` reads it straight back into a fresh buffer. - const text = if (pane.ovl) |overlay| overlay.text else ""; - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = try arena.dupe(u8, text), - .stream_b64 = &.{}, - .cursor = .{ .col = 0, .row = 0 }, - }, - }; - } - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - const extra = if (pane.ovl) |overlay| overlay.text.len else 0; - const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); - var len: usize = 0; - var lines = std.mem.splitAny(u8, full, "\n"); - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: i32 = 0; - var skip: i32 = 0; - while (lines.next()) |raw| : (row += 1) { - // Hidden overlay rows still consume prompt pins to keep them aligned. - const prompt = if (pane.mode != .tty) prompts.next() else null; - if (skip > 0) { - skip -= 1; - continue; - } - if (row > 0) { - stream[len] = '\n'; - len += 1; - } - if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { - @memcpy(stream[len..][0..overlay.text.len], overlay.text); - len += overlay.text.len; - skip = overlay.rows - 1; - continue; - }; - const shown = if (prompt) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - @memcpy(stream[len..][0..shown.len], shown); - len += shown.len; - } - return .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = pane.cols, - .rows = pane.rows, - .vweight = pane.vweight, - .terminal = .{ - .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), - .cursor = .{ - .col = pane.vt.screens.active.cursor.x, - .row = pane.vt.screens.active.cursor.y, - }, - }, - }; -} - -fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { - const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - // split yields one more item than delimiters; the extra final slot is the - // cursor row retained below. - const n_rows = std.mem.count(u8, full, "\n") + 2; - const rows = try alloc.alloc([]const u8, n_rows); - errdefer alloc.free(rows); - // Blanking a prompt row only ever SHORTENS it and the retained cursor row - // adds one separator, so the dump's length plus one bounds the join. - const text = try alloc.alloc(u8, full.len + 1); - errdefer alloc.free(text); - var at: usize = 0; - var n: usize = 0; - var it = std.mem.splitScalar(u8, full, '\n'); - while (it.next()) |raw| { - const shown = if (pit.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw - else - raw; - if (n > 0) { - text[at] = '\n'; - at += 1; - } - @memcpy(text[at..][0..shown.len], shown); - rows[n] = text[at..][0..shown.len]; - at += shown.len; - n += 1; - } - text[at] = '\n'; - at += 1; - rows[n] = text[at..][0..0]; - n += 1; - std.debug.assert(n == n_rows); - return .{ .text_alloc = text, .text = text[0..at], .rows = rows }; -} - -/// The body a terminal renders: the viewport's shell rows (prompt rows blanked -/// outside tty mode) with the edit buffer's lines standing in for the rows it -/// covers, so what you see is what the motions move over. -pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - // The VIEWPORT half is the emulator's; the row walk below is the edit - // buffer's and is shared. With no emulator the viewport is simply empty, - // and `fillBody` renders the overlay against blank rows. - const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { - const screen = pane.vt.screens.active; - // The dump has to start at COLUMN ZERO of the viewport's first row. - // `Terminal.plainString` cannot: it goes through `getTopLeft(.viewport)`, - // which hands back the viewport pin verbatim, x and all, while - // `PageList.pin` — how the colour pass finds that same row — forces x to - // 0. Reflow can leave a tracked viewport pin in the MIDDLE of a row - // (narrow the pane until a line wraps, scroll back onto the - // continuation, widen it again): from then on this pass dumped row 0 - // from that column while the colour pass paired the fragment with the - // row's first cells, so the row lost its left half and wore the wrong - // colours — every frame, until the pane snapped back to live output. - // Ghostty's own renderer walks rows and ignores that x, so column zero - // is also what the terminal itself draws. - var tl = screen.pages.getTopLeft(.viewport); - tl.x = 0; - const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint; - var rows_out: std.Io.Writer.Allocating = .init(arena); - try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false }); - const raw = try rows_out.toOwnedSlice(); - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1); - var lines = std.mem.splitScalar(u8, raw, '\n'); - var n: usize = 0; - while (lines.next()) |ln| { - vp[n] = if (pane.mode != .tty) - if (prompts.next()) |pin| - if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln - else - ln - else - ln; - n += 1; - } - std.debug.assert(n == vp.len); - break :vp vp; - }; - - const len = fillBody(null, pane, vp); - const out = try arena.alloc(u8, len); - const filled = fillBody(out, pane, vp); - std.debug.assert(filled == out.len); - return out; -} - -/// Where ONE body row's content comes from. The text pass copies bytes for it -/// and the colour pass projects the emulator's styles onto it, so handing both -/// the same answer is what keeps a colour on the row its text landed on. -pub const BodyRow = union(enum) { - /// A shell row, as a VIEWPORT index. Out-of-range values are yielded rather - /// than filtered: each consumer knows its own bound (the text pass has the - /// dumped rows, the colour pass has the live viewport) and a row nobody can - /// source is a blank row, not a skipped one. - grid: i32, - /// One line of the edit buffer, and WHICH line it is. A line the user never - /// changed still stands over the shell row it was seeded from, so the index - /// is what lets the colour pass find that row again (see `EditAnchors`). - edit: struct { line: []const u8, idx: usize }, -}; - -/// THE body row walk, shared. Both passes stepping the same iterator is what -/// makes them agree by CONSTRUCTION rather than by two copies of the same -/// arithmetic agreeing: `Pane.gridRow` and this walk disagree whenever -/// `modal.lineCount` and `splitScalar` disagree about how many rows a buffer -/// occupies (they do, for empty text: 0 against 1), and re-deriving a row's -/// anchor from `gridRow` per row instead of stepping it here put colours one -/// row off below an emptied edit buffer. -const BodyWalk = struct { - pane: *Pane, - goff: i32, - g: i32, - /// the buffer can start above the viewport: drop the lines scrolled past - skip: usize, - n: usize = 0, - lines: ?std.mem.SplitIterator(u8, .scalar) = null, - covered: i32 = 0, - line_idx: usize = 0, - - fn init(pane: *Pane) BodyWalk { - const off = pane.scroll(); - const goff = gridOffset(pane); - return .{ - .pane = pane, - .goff = goff, - // tty mode does not apply the edit buffer, so it must not be moved - // by one either. `Pane.gridRow` and `Pane.surfRow` are NOT inverses - // for a row strictly inside the buffer's covered span (surfRow - // clamps to the buffer's last line, gridRow collapses the whole - // span onto its first shell row), so a buffer left behind by - // `enterTty` — which clears every other modal remnant but not this - // one — straddling the viewport top used to start this walk ABOVE - // the viewport and slide the entire body down. - .g = if (pane.mode == .tty) goff else pane.gridRow(off), - .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0, - }; - } - - fn next(w: *BodyWalk) ?BodyRow { - while (w.n < w.pane.rows) { - if (w.lines) |*it| { - if (it.next()) |line| { - const idx = w.line_idx; - w.line_idx += 1; - // Lines scrolled off the top still count: the index names a - // line of the BUFFER, not of the visible body. - if (w.skip > 0) { - w.skip -= 1; - continue; - } - w.n += 1; - return .{ .edit = .{ .line = line, .idx = idx } }; - } - // The buffer stands in for `rows` shell rows however many lines - // it actually spelled, which is the whole slide. - w.g += w.covered; - w.skip = 0; - w.lines = null; - continue; - } - if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) { - w.lines = std.mem.splitScalar(u8, o.text, '\n'); - w.covered = o.rows; - w.line_idx = 0; - continue; - }; - const vi = w.g - w.goff; - w.g += 1; - w.n += 1; - return .{ .grid = vi }; - } - return null; - } -}; - -/// Run the terminal body row walk. A null destination counts bytes; a slice -/// fills the exact allocation made from that count. -fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize { - var walk: BodyWalk = .init(pane); - var written: usize = 0; - var first = true; - while (walk.next()) |row| { - if (!first) { - if (dst) |out| out[written] = '\n'; - written += 1; - } - first = false; - const bytes = switch (row) { - .edit => |e| e.line, - .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) - viewport[@intCast(vi)] - else - "", - }; - if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes); - written += bytes.len; - } - return written; -} - -/// WHICH edit-buffer lines still stand over a shell row. -/// -/// The buffer only ever GROWS: it starts at the row first typed on and stretches -/// to cover every row an edit since has touched, so after a few edits it spans -/// rows the user never altered. Those lines are still byte-identical to the -/// shell rows they were seeded from, and their anchor is therefore still known — -/// so they keep their colours, and only lines that actually differ go plain. -/// -/// The buffer's text is DERIVED from the rows it covers, so the untouched lines -/// appear in the same ORDER as the rows they came from. The answer is therefore -/// a MONOTONE MATCHING, and that is what this streams: one shell-row cursor -/// which only ever moves forward, advanced once per buffer line. A line claims -/// the first row at or after the cursor that its bytes equal; matching bytes is -/// the whole proof. A line that matches nothing was typed by the user, so it -/// claims no row and leaves the rows beneath it to the lines below. -/// -/// Two ALIGNED guesses — the Nth line over the Nth covered row, and the same -/// counted from the bottom — are not enough, and the counterexample is one -/// keystroke. Join two rows (backspace at column 0): the buffer loses a line -/// and gains covered rows, the two counts cancel at `lines == covered`, and both -/// guesses resolve to the SAME row, one short of where the lines below actually -/// live. Every untouched row under the join went plain. Nor is a leading and a -/// trailing RUN enough: a run stops at the first divergence, so two separate -/// edits drained the colour of every untouched line BETWEEN them. -/// -/// Cost is linear in the buffer, which the quadratic version this replaced was -/// not (walking to the Nth line per line: 35 ms a frame at a few thousand -/// lines). Every successful claim moves the cursor, so all of them together -/// scan the covered span once; only a typed line can scan without moving it, -/// and `budget` is what stops a buffer full of typed lines from paying that -/// scan per line. Exhausting it costs colour on rows further down, never -/// correctness. -const EditAnchors = struct { - /// the buffer's own text, walked in order: a line the VIEWPORT skipped still - /// consumes the row it came from, so the lines below it stay aligned - text: []const u8 = &.{}, - at: usize = 0, - shell: []const []const u8 = &.{}, - /// the covered span, absolute grid rows, as `[first, end)` - first: usize = 0, - end: usize = 0, - lines: usize = 0, - /// the line `at` names, and the first row still unclaimed - idx: usize = 0, - cursor: usize = 0, - budget: usize = 0, - active: bool = false, - - fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors { - if (o.rows <= 0 or o.row < 0) return .{}; - const shell = shellRows(p, pane) catch return .{}; - const first: usize = @intCast(o.row); - if (first >= shell.len) return .{}; - const covered: usize = @intCast(o.rows); - const lines = std.mem.count(u8, o.text, "\n") + 1; - return .{ - .text = o.text, - .shell = shell, - .first = first, - .end = @min(first + covered, shell.len), - .lines = lines, - .cursor = first, - .budget = covered + 4 * lines, - .active = true, - }; - } - - /// Where buffer line `idx` still stands over the grid, if anywhere. `idx` - /// only ever grows — both passes step `BodyWalk` from the top — so catching - /// up to it is amortised O(1) per visible row. - fn shellRow(a: *EditAnchors, idx: usize) ?Anchor { - if (!a.active or idx >= a.lines) return null; - var found: ?Anchor = null; - while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null); - return found; - } - - fn nextLine(a: *EditAnchors) ?[]const u8 { - if (a.at > a.text.len) return null; - const rest = a.text[a.at..]; - if (std.mem.indexOfScalar(u8, rest, '\n')) |n| { - a.at += n + 1; - return rest[0..n]; - } - // The last line has no terminator; one past the end ends the walk. - a.at = a.text.len + 1; - return rest; - } - - /// Where this line still stands over the grid, if anywhere. - fn claim(a: *EditAnchors, line: []const u8) ?Anchor { - // An EXACT row is the best evidence there is, so look for one first and - // look anywhere ahead: a line that merely RESEMBLES the row alignment - // offers is often the row two below, unchanged and unedited. - // - // Scanning past the cursor crosses rows that were deleted or joined - // away, and the line's bytes are what justify the crossing — so an - // EMPTY line may not do it. Empty is not evidence: it equals every - // blank row in the span, and splitting a row makes exactly that. Two - // keystrokes (Home, Enter) used to hand the blank row below the last - // output to the new empty line and take every coloured row in between - // out of reach of the lines that owned them. - const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end; - var k = a.cursor; - while (k < end) : (k += 1) { - if (a.budget == 0) return null; - a.budget -= 1; - if (!std.mem.eql(u8, line, a.shell[k])) continue; - a.cursor = k + 1; - return .{ .row = @intCast(k) }; - } - // No row spells this line, so it is either the row the alignment offers - // WITH AN EDIT IN IT, or text typed from nothing. The bytes shared at - // the two ends decide which — and, when it is an edit, exactly how much - // of the row's colour the line still has a right to. - if (a.cursor >= a.end) return null; - const shell = a.shell[a.cursor]; - var p: usize = 0; - while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1; - var s: usize = 0; - const room = @min(line.len, shell.len) - p; - while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1; - if (p + s == 0) return null; - // Accept when the row accounts for the whole LINE (nothing was typed; - // the line is a piece of the row, which is the top half of a split), or - // when most of the ROW survived in it (an ordinary edit). Otherwise this - // is new text that happens to share an edge with its neighbour, and - // colouring it would hand it a colour that was never its own. - if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null; - const row = a.cursor; - // A line that stopped short of the row's END leaves the rest of that row - // to the NEXT line. Splitting a row in two is exactly that, and it is - // why the bottom half can still find its colours: they are in the tail - // of the row the top half only partly covered. - if (s > 0 or p >= shell.len) a.cursor += 1; - return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len }; - } -}; - -/// WHERE a body row's colours come from, and HOW MUCH of the row they cover. -/// -/// A row whose text is the grid's own takes the grid's colours end to end. A -/// row the user has EDITED still holds the row's own bytes at its two ends — -/// they are the same bytes, provably — and those keep their colours; only what -/// was typed between them has no cell under it and so takes none. Dropping the -/// whole row instead was the loudest colour bug in the editor: one keystroke -/// that changed one character's case turned every column of a coloured row -/// grey. -const Anchor = struct { - /// the row, absolute while it comes from `EditAnchors`, viewport once - /// `recolorAnsi` has subtracted the walk's offset - row: i32, - /// bytes at the START of the line that are still the row's own, and bytes at - /// its END. The default says ALL of it: an exact match, or a `.grid` row, - /// which is the grid's text by construction. - prefix: usize = std.math.maxInt(usize), - suffix: usize = 0, - /// the row's own dumped length — what the suffix is measured from on the - /// GRID side, where the edit may have changed the byte count - shell_len: usize = 0, - - fn whole(an: Anchor) bool { - return an.prefix == std.math.maxInt(usize); - } -}; - -/// tty colors: recolor each visible body cell from the emulator's own style so -/// raw output keeps its ansi colors — in EVERY mode, not just `.tty`, because a -/// body row's colour has the same origin its text does and `BodyWalk` already -/// knows it. -/// -/// Editing moves shell rows around: the edit buffer's lines stand in for the -/// rows it covers, so everything below slides, and `promptRow` left-hugs a -/// prompt row so what was typed starts at column 0. A colour therefore needs -/// exactly two translations, and takes each from the pass that made it: -/// -/// * ROW — step `BodyWalk`, the same iterator `fillBody` steps. A `.grid` row -/// names the viewport row whose bytes were drawn; an `.edit` row is the -/// user's own text with no shell row underneath, so it keeps the body style. -/// Sharing the walk is load-bearing: deriving the anchor independently (from -/// `Pane.gridRow`) put colours one row off wherever that arithmetic and this -/// walk disagreed about a buffer's height. -/// * COLUMN — pair the PRINTED graphemes with the grid cells that spelled them, -/// starting at the cell `promptCut` says the hug dropped to. Not `cut + c`: -/// the two sides disagree about how many columns a cluster is worth (ghostty -/// splits `👨‍👩‍👧` across three wide cells and spells it once; this surface -/// prints that one grapheme two columns wide), so column arithmetic walks off -/// the glyph it means and every cell after it wears a neighbour's colour. -/// `body` is the very text the caller just printed, which is what makes the -/// pairing exact rather than a second guess at clustering. -/// -/// In tty mode the buffer is not applied and no prompt is hugged, so the row -/// anchor collapses to the viewport row. That is not quite "as it always did": -/// the walk starts at `Pane.gridRow(pane.scroll())` like `fillBody`, so where a -/// stale buffer skews that start, the colours now follow the text instead of -/// silently disagreeing with it. -pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { - // No emulator, no ANSI cells: the whole pass — and the 256-colour theme - // projection behind it — is compiled out. - if (comptime !enabled) return; - const s = &p.surface; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - var filtered_storage: FilteredColors = undefined; - const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { - // Enumerating the indexed answers is the whole cost of the filter that - // is NOT per cell, so it gets to be visible on its own: this is the - // number that says whether the tables should be cached across frames - // rather than rebuilt per pass. Measured at 2.9 us warm against a - // 117 us `paneRecolor`, which is why they are not. - const tz_filter = tracy.zone(@src(), "filterInit"); - defer tz_filter.end(); - filtered_storage = FilteredColors.init(p, pane); - break :blk &filtered_storage; - } else null; - // DECSCNM, read once: the filtered palette folds it in itself, the raw - // path needs it per cell. - const scnm = pane.vt.modes.get(.reverse_colors); - const pages = &pane.vt.screens.active.pages; - // The text pass bounds its rows by the dump it was handed; this one has the - // live viewport, so it bounds by the viewport's own height. Both bounds - // exist for the same reason and NEITHER is `pin`: `PageList.pin` resolves a - // viewport row by walking DOWN the pagelist, so a viewport scrolled back - // answers happily for rows below its bottom edge — which painted the - // scrollback's colours onto rows the text pass had left blank. - const vp_rows: i32 = @intCast(scrollbar(pane).len); - // Which buffer lines the user has not actually changed, so a row swallowed - // by a growing buffer keeps the colour it still stands over. - var anchors: EditAnchors = if (pane.mode != .tty) - if (pane.ovl) |o| .init(p, pane, o) else .{} - else - .{}; - var walk: BodyWalk = .init(pane); - // The printed body, one line per body row, stepped ONCE per row alongside - // the walk. Asking for the Nth line per row instead re-scanned the whole - // body every time, which made a tall pane's render superlinear. - var lines = std.mem.splitScalar(u8, body, '\n'); - var vr: u16 = 0; - while (walk.next()) |row| : (vr += 1) { - if (vr >= body_h) break; - // Before any early exit below, or the lines fall out of step with rows. - const text = lines.next() orelse ""; - const anchor: Anchor = switch (row) { - // A line the user typed from nothing has no cell under it; one they - // only had swallowed, or edited a piece of, still names the row its - // bytes came from and how much of it is still that row's. - .edit => |e| blk: { - var an = anchors.shellRow(e.idx) orelse continue; - an.row -= walk.goff; - break :blk an; - }, - .grid => |v| .{ .row = v }, - }; - const vi = anchor.row; - if (vi < 0 or vi >= vp_rows) continue; - const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; - // The prompt the text pass dropped, added back as a starting CELL. - // Gated on the ROW FLAG first, exactly as `bodyText` gates `promptRow`: - // `promptCut` answers for the whole row under - // `config.tty_blank == .prompt_and_input`, so asking it about a row the - // text pass never asked about would blank colours nobody hid. - const cut: u16 = if (pane.mode == .tty) 0 else cut: { - if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0; - break :cut switch (promptCut(row_pin)) { - .keep => 0, - // Blanked end to end: the row shows nothing of the grid, so - // projecting the prompt's own colours onto it would be a lie. - .blank => continue, - .cut => |n| std.math.cast(u16, n) orelse continue, - }; - }; - // The text this row printed is walked grapheme by grapheme alongside the - // cells that spelled it. Both walks are driven by real data — the - // printed bytes and the cells' own dumped byte counts — so neither has - // to guess how many columns the other gives a cluster. - // The hug can empty a row outright: a prompt whose command did not fit - // leaves ghostty a styled spacer and nothing printable. The row DRAWS - // nothing, so nothing on it may take the grid's colour — the same - // reasoning as `.blank` above, reached by a different route. - if (cut > 0 and text.len == 0) continue; - var at: usize = 0; - var sc: u16 = 0; - var gc: u16 = cut; - // Shell bytes crossed so far, which is how the row's TAIL is found again - // after an edit: the printed text and the grid agree byte for byte over - // `prefix` and over `suffix`, and nowhere in between. - var sb: usize = 0; - const mine_from = @min(anchor.prefix, text.len); - const mine_to = text.len - @min(anchor.suffix, text.len); - var crossed = false; - while (at < text.len and sc < tw) { - const stop = modal.nextGrapheme(text, at); - if (stop <= at) break; - // What the glyph occupies HERE: `print` leaves an empty cell under a - // double-width one, and `fill` writes a space, so a zero-length cell - // is a spacer and nothing else. It is a property of the SURFACE, so - // it is known before any cell is consumed — which is what lets the - // user's own text spend its columns without spending the row's. - // - // This rule assumes the printed text holds no `\t` and no `\r`: - // `Surface.print` expands a tab into `config.tab_width` cells and - // draws nothing at all for a carriage return, either of which would - // slide every later colour on the row. The assumption is ghostty's, - // not ours — its row dump expands tabs to real spaces and replaces - // undecodable bytes with U+FFFD — so it holds for anything sourced - // from the grid, and an anchor only ever covers bytes that ARE such - // a row's. Feed this text from anywhere else and the span rule is - // the thing that breaks first. - const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1; - // Between the row's own two ends lie the bytes the user typed. No - // cell spelled them, so they take no colour and spend no grid - // column: the row's tail then still lines up with the line's tail. - if (at >= mine_from and at < mine_to) { - sc += span; - at = stop; - continue; - } - // Crossing back into the row's own bytes: step over the cells whose - // bytes the edit replaced. `shell_len - suffix` is where the row's - // own tail starts on the GRID side, which is not where it starts in - // the line whenever the edit changed the byte count. - if (at >= mine_to and !crossed) { - crossed = true; - const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len); - while (sb < upto) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - sb += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - } - } - const want = stop - at; - // Consume every cell that contributed to this grapheme. A cluster - // ghostty split across several cells is still ONE printed glyph. - var covered: usize = 0; - var style: ?pardes.CellStyle = null; - while (covered < want) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head) - style = cellStyle(p, ci, filtered, scnm); - covered += dumpedBytes(row_pin, ci.cell); - gc = std.math.add(u16, gc, 1) catch break; - // A spacer contributes no bytes; without this the loop would - // spin on a row that ends in one. - if (covered == 0 and gc >= pane.cols) break; - } - // A wide cell's tail contributes NO bytes, so the loop above stops - // on it rather than past it. Step over any tail now: leaving `gc` on - // one pairs the next surface column with the cell before it, which - // left an unpainted hole beside a row-final CJK glyph and pushed - // every colour after it one column right. - while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| { - if (t.cell.wide != .spacer_tail) break; - gc = std.math.add(u16, gc, 1) catch break; - } - if (style) |st| for (0..span) |k| { - const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr); - // sparse projection: bodyText already painted every glyph, so - // only a filter (which theme-keys blank/default cells too) - // touches these. - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = st; - }; - sb += covered; - sc += span; - at = stop; - } - // Past the text: the row's remaining cells carry colour but no glyph - // (an erase-to-end-of-line under a background). One cell, one column - // from here, with two exceptions on the grid side. - // - // Only a row that ENDS in the row's own bytes may ask what lies past - // them. Where the user's own text runs to the end of the line, the next - // cells still spell bytes the edit removed, and painting the line's - // margin from those would dress it in the colours of text that is no - // longer there. - var tail: ?pardes.CellStyle = null; - if (anchor.whole() or anchor.suffix > 0) { - while (sc < tw) { - const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break; - gc = std.math.add(u16, gc, 1) catch break; - // A TAIL spells nothing and owns no column of its own, so it - // moves the grid on without spending a surface column. A HEAD - // does own its column — it is the gap ghostty leaves where a - // wide glyph would not fit, and it carries the row's background - // — so it is painted like any other cell. Skipping it left the - // last column of a coloured row bare, because a head is by - // construction that row's final cell. - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + sc, body_y + vr); - sc += 1; - const style = cellStyle(p, ci, filtered, scnm); - tail = style; - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - } - // The grid can run out before the surface does: a cluster ghostty - // spends four cells on may print in two columns here, so a row - // ending in one has columns with no cell left to ask. The row's - // background does reach its edge on the grid, so carry the last - // cell's answer across rather than leaving a notch of pane colour at - // the margin. - if (tail) |style| while (sc < tw) : (sc += 1) { - const cell = s.at(tx + sc, body_y + vr); - if (cell.default and filtered == null) continue; - cell.default = false; - cell.style = style; - }; - } - } -} - -/// `scnm` is DECSCNM (`\x1b[?5h`), which swaps only the terminal's DEFAULT -/// colour roles — explicit SGR colours stay explicit. `FilteredColors` applies -/// it by swapping the theme's two defaults; the raw path resolves a `.none` -/// colour through `ghostColor`, whose `is_bg` argument chooses which default it -/// means, so flipping that argument is the same swap. Without it reverse video -/// simply vanished whenever `tty_filter` was off. -fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle { - const style = ci.style(); - var cs: pardes.CellStyle = .{ - .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm), - .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - // Style.bg above already resolves Ghostty's color-only cell variants for - // the filtered path. Preserve the established direct translation outside - // it, where indexed colours are intentionally allowed to reach the host. - if (filtered == null) switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - }; - return cs; -} - -/// Per-render resolver, in two stages. The source palette is materialized -/// through Ghostty's public xterm API, so OSC 4 changes participate without -/// reaching into the emulator's private state. -/// -/// STAGE ONE is the default foreground and background roles, because they are -/// the anchors: Ghostty generates the whole 256-colour projection from them, -/// and `default_bg` below is the page every other colour is judged against. -/// -/// STAGE TWO is everything else. Truecolour and visually overridden entries are -/// reduced to the nearest canonical Ghostty palette key; the key then indexes -/// the theme palette. Repeated RGBs pay that search only once per frame. A -/// FOREGROUND additionally has to clear `config.tty_filter_min_contrast` -/// against `default_bg` — the reduction is an RGB distance and knows nothing -/// about the page, and the projection's cube corners are the anchors -/// themselves, so without the gate the nearest key to a truecolour extreme is -/// the background and the text is painted in the colour of the page. -const FilteredColors = struct { - source: GColor.Palette, - target: *const GColor.Palette, - theme_bg: GColor.RGB, - theme_fg: GColor.RGB, - dynamic_bg: ?GColor.RGB, - dynamic_fg: ?GColor.RGB, - /// Stage one's background, mapped: exactly what `bg` answers for a cell - /// that names no colour of its own, and therefore the page a foreground - /// has to stay legible against. - default_bg: GColor.RGB, - /// What a foreground too near `default_bg` becomes instead. - fallback_fg: GColor.RGB, - /// `default_bg`'s luminance, computed once. `legible` runs per CELL and - /// asks for the contrast ratio against this same colour every time; the - /// half of the ratio that belongs to the background never changes. - default_bg_luminance: f64, - /// Every answer the INDEXED path can give, resolved before the first - /// cell is read. - /// - /// A cell that names a palette colour has 256 possible inputs, and this - /// filter is a pure function of them: the OSC 4 comparison, the theme - /// projection and the contrast gate all depend only on the index and on - /// state that is fixed for the whole pass. So the per-cell chain - /// collapses to one array read, and `legible` - six libm `pow` calls - /// through `RGB.contrast`, which profiling put at 12 of 43 draw samples - /// - stops being a per-cell cost entirely. - /// - /// Only TRUECOLOUR still searches: it carries arbitrary RGB, so its - /// answers cannot be enumerated and the direct-mapped cache below is - /// what keeps it cheap. - fg_for_palette: [256]pardes.Color = undefined, - bg_for_palette: [256]pardes.Color = undefined, - /// The two answers for a cell that names no colour of its own. - fg_default: pardes.Color = undefined, - bg_default: pardes.Color = undefined, - // Direct-mapped rather than append-only: a frame which encounters more - // than the cache's capacity must not strand every later (and repeated) - // colour on the 256-entry nearest-key scan. The RGB hash spreads the - // common 6x6x6 cube values instead of keying on their low bits. - cache_rgb: [256]GColor.RGB = undefined, - cache_key: [256]u8 = undefined, - cache_valid: [256]bool = @splat(false), - - fn init(p: *Pardes, pane: *const Pane) FilteredColors { - var source = GColor.default; - for (&source, 0..) |*rgb, i| - rgb.* = pane.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*; - const theme = p.theme(); - var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg); - var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg); - var dynamic_bg = pane.vt.colorForXterm(.{ .dynamic = .background }); - var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }); - // DECSCNM swaps only the terminal's default color roles; explicit SGR - // colors stay explicit. Reuse Ghostty's parsed mode instead of trying - // to infer the escape from cells, just as its renderer does. - if (pane.vt.modes.get(.reverse_colors)) { - std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); - std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); - } - var self: FilteredColors = .{ - .source = source, - .target = p.tty_filter_palette.get(theme), - .theme_bg = theme_bg, - .theme_fg = theme_fg, - .dynamic_bg = dynamic_bg, - .dynamic_fg = dynamic_fg, - .default_bg = theme_bg, - .fallback_fg = theme_fg, - .default_bg_luminance = luminanceOf(theme_bg), - }; - // Stage one, finished before a single other colour is mapped. OSC 11 - // moves the page, so the floor moves with it; the anchor that survives - // as the fallback is then whichever of the theme's own pair can still - // be seen on it, which on an untouched terminal is always the theme's - // foreground (a background has no contrast with itself). - if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb); - self.default_bg_luminance = luminanceOf(self.default_bg); - if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg)) - self.fallback_fg = self.theme_bg; - - // Stage two, ENUMERATED rather than answered per cell. Everything the - // indexed path needs is now fixed, and its input is a u8, so every - // answer it can ever give is computed here - once for the pass, not - // once for each of the tens of thousands of cells that will ask. - self.fg_default = asPardesColor(self.legible( - if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg, - )); - self.bg_default = asPardesColor(self.default_bg); - for (&self.source, 0..) |current, i| { - const idx: u8 = @intCast(i); - const mapped = self.paletteRgb(idx, current); - self.fg_for_palette[idx] = asPardesColor(self.legible(mapped)); - self.bg_for_palette[idx] = asPardesColor(mapped); - } - return self; - } - - /// One array read for every colour a cell can NAME. Only truecolour, - /// whose 16.7M inputs cannot be enumerated, reaches the reduction - and - /// `style.fg` is now asked only on that path, because the other two - /// answers no longer depend on it. - fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { - return switch (style.fg_color) { - .none => self.fg_default, - .palette => |idx| self.fg_for_palette[idx], - .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{ - .default = self.dynamic_fg orelse self.theme_fg, - .palette = &self.source, - .bold = null, - })))), - }; - } - - fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { - switch (cell.content_tag) { - .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data], - .bg_color_rgb => {}, - else => switch (style.bg_color) { - .none => return self.bg_default, - .palette => |idx| return self.bg_for_palette[idx], - .rgb => {}, - }, - } - // Truecolour, from either the cell or its style. - return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?)); - } - - /// Stage two's only rule, and a FOREGROUND rule: a background IS the page - /// for whatever is drawn over it, so holding one away from itself would be - /// meaningless. An ANSI black on a dark theme and a truecolour white on a - /// light one both reduce to the key whose projected value is the page — - /// ratio 1.000, invisible text — and both land here instead. - fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB { - if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >= - config.tty_filter_min_contrast) return rgb; - return self.fallback_fg; - } - - /// Preserve an ordinary indexed colour's semantic key. A value changed by - /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way - /// as truecolour. Setting an entry to its exact original value is visually - /// indistinguishable and correctly takes this fast path. - fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB { - if (current.eql(GColor.default[idx])) return self.target[idx]; - return self.keyedRgb(current); - } - - fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB { - return self.target[self.nearestKey(rgb)]; - } - - fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 { - const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b; - const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24); - if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb)) - return self.cache_key[slot]; - - var best: u8 = 0; - var best_distance: u32 = std.math.maxInt(u32); - for (GColor.default, 0..) |candidate, i| { - const distance = colorDistance(rgb, candidate); - // Strict comparison makes duplicate-colour ties stable at the - // lowest canonical xterm key. - if (distance < best_distance) { - best_distance = distance; - best = @intCast(i); - } - } - self.cache_rgb[slot] = rgb; - self.cache_key[slot] = best; - self.cache_valid[slot] = true; - return best; - } -}; - -/// W3C relative luminance per 8-bit channel, precomputed. -/// -/// ghostty's `RGB.componentLuminance` ends in `std.math.pow(f64, x, 2.4)` -/// (color.zig:474), `luminance` calls it three times, and `contrast` calls -/// `luminance` for BOTH colours — so `legible`'s single `rgb.contrast(bg)` is -/// up to six libm `pow` calls, per cell, per frame. Profiling the AppKit shell -/// put `cellStyle -> FilteredColors.legible -> RGB.contrast` at 12 of 43 draw -/// samples; the whole rest of `recolorAnsi` was 3. -/// -/// The input is a `u8`. There are 256 possible answers. This is the table. -/// -/// Bit-identical to ghostty's function by construction — same expression, -/// evaluated at comptime — so the filter's decisions do not move. The -/// equivalence test below pins that. -const channel_luminance: [256]f64 = blk: { - @setEvalBranchQuota(20000); - var table: [256]f64 = undefined; - for (&table, 0..) |*slot, c| { - const normalized: f64 = @as(f64, @floatFromInt(c)) / 255; - slot.* = if (normalized <= 0.03928) - normalized / 12.92 - else - std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4); - } - break :blk table; -}; - -fn luminanceOf(rgb: GColor.RGB) f64 { - return 0.2126 * channel_luminance[rgb.r] + - 0.7152 * channel_luminance[rgb.g] + - 0.0722 * channel_luminance[rgb.b]; -} - -/// ghostty's `RGB.contrast` with both luminances already in hand. -fn contrastOf(a_luminance: f64, b_luminance: f64) f64 { - const lighter = @max(a_luminance, b_luminance); - const darker = @min(a_luminance, b_luminance); - return (lighter + 0.05) / (darker + 0.05); -} - -fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 { - const dr = @as(i32, a.r) - @as(i32, b.r); - const dg = @as(i32, a.g) - @as(i32, b.g); - const db = @as(i32, a.b) - @as(i32, b.b); - return @intCast(dr * dr + dg * dg + db * db); -} - -test "the luminance table answers exactly what ghostty computes" { - // The filter's decisions are a threshold comparison on these numbers, so - // "close enough" is not enough: one ULP either side of - // `tty_filter_min_contrast` is a different colour on screen. Every - // channel value, and the pairs a real pass actually asks about. - for (0..256) |i| { - const c: u8 = @intCast(i); - const grey: GColor.RGB = .{ .r = c, .g = c, .b = c }; - try std.testing.expectEqual(grey.luminance(), luminanceOf(grey)); - } - // Channel weights are asymmetric, so a grey ramp alone would not catch a - // transposed coefficient. The palette is what the tables enumerate. - for (GColor.default) |candidate| { - try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate)); - for (GColor.default) |page| { - try std.testing.expectEqual( - candidate.contrast(page), - contrastOf(luminanceOf(candidate), luminanceOf(page)), - ); - } - } -} - -test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - pane.tty_filter = false; - - // 1: ANSI base key; 196: extended key; true red exactly matches canonical - // key 196. The two backgrounds repeat key 25 as indexed and truecolour. - // Erase-to-EOL under that background makes Ghostty color-only cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++ - "\x1b[38;5;196mB" ++ - "\x1b[38;2;255;0;0mC" ++ - "\x1b[0;48;5;25mD" ++ - "\x1b[0;48;2;0;95;175mE" ++ - "\x1b[0;1;2;3;4;5;7;8;9mF" ++ - "\x1b[0;48;5;25m\x1b[K" ++ - "\r\n\x1b[0;38;5;2m界" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const raw = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg); - - pane.tty_filter = true; - _ = frame.reset(.retain_capacity); - const filtered = try p.render(frame.allocator()); - var expected_cache: FilterPalette = .{}; - const expected = expected_cache.get(p.theme()); - try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg); - try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg); - try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg); - - const attrs = filtered.at(tx + 5, body_y).style; - try testing.expect(attrs.bold); - try testing.expect(attrs.dim); - try testing.expect(attrs.italic); - try testing.expect(attrs.blink); - try testing.expect(attrs.reverse); - try testing.expect(attrs.invisible); - try testing.expect(attrs.strikethrough); - try testing.expectEqual(.single, attrs.ul); - - const erased = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?; - try testing.expectEqual(.bg_color_palette, erased.cell.content_tag); - try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg); - try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg); - try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style); - // A filtered terminal never delegates either colour to a backend palette, - // including cells which were empty/default before the pass. - for (0..r.w - config.GUTTER) |col| { - const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y); - try testing.expect(!cell.default); - switch (cell.style.fg) { - .rgb => {}, - else => return error.FilteredForegroundWasNotRgb, - } - switch (cell.style.bg) { - .rgb => {}, - else => return error.FilteredBackgroundWasNotRgb, - } - } - - // Colors remains the global master gate. The pane remembers Filter while - // ANSI projection is dormant, and resumes it without replaying VT bytes. - p.settings.colors = false; - _ = frame.reset(.retain_capacity); - const plain = try p.render(frame.allocator()); - try testing.expect(pane.tty_filter); - try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg); - p.settings.colors = true; - - // OSC 4 changes the value behind an existing indexed cell. Filter treats - // that arbitrary value like truecolour, while toggling remains purely a - // presentation operation and cannot alter Ghostty's query answer. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); - const osc_red = pane.vt.colorForXterm(.{ .palette = 1 }).?; - try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 })); - pane.tty_filter = false; - pane.tty_filter = true; - try testing.expect(osc_red.eql(pane.vt.colorForXterm(.{ .palette = 1 }).?)); - _ = frame.reset(.retain_capacity); - const osc_palette = try p.render(frame.allocator()); - try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg); - - // Dynamic default foreground/background colours key every default cell, - // including the otherwise blank end of the row. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++ - "\x1b]11;#5f5f5f\x1b\\" } }); - const dyn_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }).?; - const dyn_bg = pane.vt.colorForXterm(.{ .dynamic = .background }).?; - try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 })); - try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 })); - _ = frame.reset(.retain_capacity); - const dynamic = try p.render(frame.allocator()); - const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), blank.bg); - - // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for - // the dynamic/default roles, and here the swap turns this cell into a - // COLLISION: its explicit ANSI foreground is the OSC 4 red keyed to 196, - // and reverse video has just made that same red the page. Stage two - // refuses the mapping rather than painting red on red, so the ink becomes - // the anchor still visible on it — under the swap, the theme's own - // background colour. Unreversed, the very same cell keeps key 196. - const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; - try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse); - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg); - const reversed_explicit = reversed.at(tx, body_y).style; - try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg); - try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg); - try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg)); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } }); - _ = frame.reset(.retain_capacity); - const unreversed = try p.render(frame.allocator()); - const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style; - try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg); - try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg); - - // The cache is keyed by values, not a theme name. Replacing a custom - // theme in place immediately recolours already-rendered indexed cells. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } }); - var custom = p.theme().*; - custom.name = try p.gpa.dupe(u8, "same-name"); - custom.palette = null; - custom.kw = .{ 1, 2, 3 }; - p.custom_theme = custom; - p.custom_theme_active = true; - _ = frame.reset(.retain_capacity); - const custom_first = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg); - if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 }; - _ = frame.reset(.retain_capacity); - const custom_second = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg); -} - -test "terminal Filter keeps extended keys dark-to-light on a light theme" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); - defer p.deinit(); - // Curated order is a public theme contract: helix, dark, acme. - p.settings.theme = 2; - try std.testing.expectEqualStrings("acme", p.theme().name); - var cache: FilterPalette = .{}; - const palette = cache.get(p.theme()); - try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?))); - try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?))); -} - -test "terminal Filter preserves exact palette-null light theme default roles" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - - var light = p.theme().*; - light.name = try p.gpa.dupe(u8, "filter-light-defaults"); - light.bg = .{ 0xf8, 0xf8, 0xf8 }; - light.fg = .{ 0x38, 0x38, 0x38 }; - light.palette = null; - p.custom_theme = light; - p.custom_theme_active = true; - - const pane = p.panes[0].?; - try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground })); - try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .background })); - pane.tty_filter = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const ordinary = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg); - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - _ = frame.reset(.retain_capacity); - const reversed = try p.render(frame.allocator()); - try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg); -} - -test "terminal Filter maps the default roles before it maps anything else" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Stage one is the two anchors, and they are taken from the theme WHOLE: - // a cell that names no colour of its own is not routed through the - // projection at all, so the page and the ink are exactly the theme's. - for (0..3) |t| { - p.settings.theme = @intCast(t); - const stage_one = FilteredColors.init(p, pane); - // `dark` declares no background of its own, which is exactly why the - // resolver reads the tag colours as the fallback rather than `.?`. - const theme = p.theme(); - try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg))); - try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg))); - // With no OSC 11 in play the mapped page IS that anchor, and the - // fallback is the other one: a background never contrasts with itself. - try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg)); - try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg)); - } - - // OSC 11 moves the page, and stage one moves with it: the reference the - // floor is measured against becomes the PROJECTED dynamic background, not - // the theme's, because that is what `bg` paints behind a default cell. - p.settings.theme = 0; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } }); - var moved = FilteredColors.init(p, pane); - try testing.expect(!moved.default_bg.eql(moved.theme_bg)); - try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f }))); -} - -test "terminal Filter refuses a foreground that would collapse onto the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - try testing.expect(pane.tty_filter); - - // Two ways to land on the page, one per theme orientation. On the light - // theme the projection's white corner IS the paper, so a truecolour white - // reduces to it; on a dark theme the same is true of ANSI black, which a - // shell reaches for with a bare `\x1b[30m` and which takes the semantic - // fast path rather than the nearest-key scan. Both used to render text in - // the colour of the page under it. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++ - "\x1b[0;30mB" ++ - "\x1b[0;31mR" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - for (0..3) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - const page = asPardesColor(fc.default_bg); - const rescued = asPardesColor(fc.fallback_fg); - _ = frame.reset(.retain_capacity); - const g = try p.render(frame.allocator()); - - // The colour each of the three would have been given with no floor. - const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 }); - const raw_black = fc.paletteRgb(0, GColor.default[0]); - const raw_red = fc.paletteRgb(1, GColor.default[1]); - - for ([_]struct { at: u16, raw: GColor.RGB }{ - .{ .at = 0, .raw = raw_white }, - .{ .at = 1, .raw = raw_black }, - .{ .at = 2, .raw = raw_red }, - }) |case| { - const cell = g.at(tx + case.at, body_y).style; - try testing.expectEqual(page, cell.bg); - if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) { - // Refused: the projection's answer is not painted, the anchor is. - try testing.expectEqual(rescued, cell.fg); - try testing.expect(!std.meta.eql(cell.fg, cell.bg)); - } else { - // Cleared the floor, so stage two leaves it exactly alone. - try testing.expectEqual(asPardesColor(case.raw), cell.fg); - } - // Either way a filtered cell delegates neither colour to a backend. - switch (cell.fg) { - .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >= - config.tty_filter_min_contrast), - else => return error.FilteredForegroundWasNotRgb, - } - } - - // At least one of the three has to have been a real collapse, or this - // theme proved nothing: white on the light theme, black on the dark. - try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or - raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast); - // A saturated red is never the page on any curated theme. - try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - } -} - -test "terminal Filter holds every projected foreground off the page" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - - // The invariant over the WHOLE projection rather than a sampled colour: - // whatever key a foreground reduces to, what stage two hands back clears - // the floor. A background is exempt by construction and must stay so — - // `bg` is what the floor is measured against. - for (0..3) |t| { - p.settings.theme = @intCast(t); - var fc = FilteredColors.init(p, pane); - var refused: usize = 0; - for (fc.target, 0..) |projected, key| { - const ink = fc.legible(projected); - try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast); - if (!ink.eql(projected)) { - refused += 1; - try testing.expect(ink.eql(fc.fallback_fg)); - // Only ever refused for being too near the page. - try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast); - } - // The key a background asks for is handed back untouched, including - // the one whose value is the page itself. - try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])])); - } - // Every curated theme owns at least one collapsing key — that is why - // the floor exists — and the floor must not be flattening the palette. - try testing.expect(refused > 0); - try testing.expect(refused < fc.target.len / 8); - } -} - -test "tty ansi colors follow the prompt hug into normal mode" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const blue: pardes.Color = .{ .index = 4 }; - - // tty mode projects the emulator's ansi colours cell for cell. - pane.mode = .tty; - p.shell_rows.stale = true; - const tty = try p.render(frame.allocator()); - try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg); - try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg); - - // Normal mode hugs the prompt away, so `R` starts at column 0 — and its - // colour comes with it. The two cells the prompt occupied are the COLUMN - // anchor `promptCut` hands back, which is the only reason the red lands on - // the R the user can see instead of two cells to the right of it. - pane.mode = .normal; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const norm = try p.render(frame.allocator()); - try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme()); - try testing.expectEqual(red, norm.at(tx, body_y).style.fg); - try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg); -} - -test "an edit buffer slides shell rows and their colors together" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const green: pardes.Color = .{ .index = 2 }; - const blue: pardes.Color = .{ .index = 4 }; - - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - try testing.expectEqual(red, before.at(tx, body_y).style.fg); - try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg); - try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); - - // Four lines of typed text standing in for the ONE shell row `AAA` was: - // every row below slides down by three, and `surfRow` is the arithmetic - // that says so. The colours have to take the same three rows, or `BBB` - // would be painted green three rows above where it is now drawn. - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - - try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme()); - try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme()); - try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg); - try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg); - - // ...and the rows the user typed are the user's own text: no shell row - // sits under them, so nothing projects a colour onto them. - for (0..4) |i| { - const cell = after.at(tx, body_y + @as(u16, @intCast(i))); - try testing.expect(!std.meta.eql(red, cell.style.fg)); - try testing.expect(!std.meta.eql(green, cell.style.fg)); - try testing.expect(!std.meta.eql(blue, cell.style.fg)); - } -} - -test "a combining mark in the prompt keeps the command and its colors aligned" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC` - // typed at it. The cell walk that finds the prompt's end must step ONE - // grapheme for that cell, not one per stored codepoint: stepping twice ate - // the `A`, and left every colour a cell to the left of its glyph with the - // last one stranded on a blank. - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme()); - try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme()); - try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg); - try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg); - // ...and no colour past the end of what the row actually says - try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg)); -} - -test "colors are never taken from shell rows below the viewport" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - // Raw palette, so a leaked background reads back as `.index` — the theme - // filter would repaint every blank cell and hide the evidence. - pane.tty_filter = false; - pane.mode = .normal; - - // Sixty rows, each a distinct background, so a leaked colour names the row - // it leaked from. - for (0..60) |i| { - var buf: [32]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - scrollGrid(pane, -20); - - // ONE buffer line standing in for SIX shell rows: everything below slides - // UP five, so the last rows of the body resolve past the viewport's bottom - // edge. `PageList.pin` answers for those rows anyway — it walks down the - // pagelist, not the viewport — so without a bound of its own this pass - // painted the scrollback's colours onto rows the text pass left blank. - const anchor = gridOffset(pane); - pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h - pardes.BOX_H; - - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - // A body row the text pass left blank has no shell row under it, so no - // ANSI background may have reached it. Every colour in the payload above is - // an indexed one, so a leak is exactly an `.index` background on a blank row. - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - var blank = true; - var c: u16 = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false; - } - if (!blank) continue; - c = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - const bg = s.at(tx + c, body_y + vr).style.bg; - try testing.expect(std.meta.activeTag(bg) != .index); - } - } -} - -test "a row the edit buffer only swallowed keeps its color" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - const green: pardes.Color = .{ .index = 2 }; - const blue: pardes.Color = .{ .index = 4 }; - - // The buffer only ever grows, so after a few edits it covers rows nobody - // touched. Here it spans all three and only the MIDDLE line differs: the - // first and last are still byte-identical to the shell rows they were - // seeded from, so they still stand over them and keep their colours. - pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); - try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme()); - try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(red, s.at(tx, body_y).style.fg); - try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg); - // ...and the line that actually changed is the user's own text now - try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg)); -} - -test "an edit buffer reaching past the dumped rows colors nothing from row zero" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const red: pardes.Color = .{ .index = 1 }; - - // Covers far more rows than the grid was ever dumped for, so the anchor - // table cannot be built and answers "no shell row" for every line. The - // zeroed table must not read as "the last line sits on the buffer's first - // row", which claimed row zero's colour and underflowed on every line after. - pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme()); - var i: u16 = 1; - while (i <= 3) : (i += 1) { - try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg)); - } -} - -/// TTY MODE IS THE ORACLE. It paints the viewport row for row and column for -/// column, so whatever it shows on a glyph is what that glyph's colour IS. -/// Normal mode may move a glyph LEFT (the prompt hug) but must never change its -/// colour, so the comparison aligns by glyph rather than by column: for each -/// row the shift is recovered by finding where normal mode's glyph run sits in -/// tty mode's, without asking the code under test what it did. -/// -/// Returns the number of cells whose style disagrees; `note` labels the report. -fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize { - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; - const cols: usize = r.w -| config.GUTTER; - - const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle }; - const glyphAt = struct { - fn f(sn: Snap, i: usize) []const u8 { - return sn.text[i][0..sn.len[i]]; - } - }.f; - var shot: [2]Snap = undefined; - for (&shot) |*sn| { - sn.text = try gpa.alloc([7]u8, rows * cols); - sn.len = try gpa.alloc(u8, rows * cols); - sn.style = try gpa.alloc(pardes.CellStyle, rows * cols); - } - defer for (&shot) |*sn| { - gpa.free(sn.text); - gpa.free(sn.len); - gpa.free(sn.style); - }; - - for ([_]pardes.Mode{ .tty, .normal }, 0..) |mode, i| { - pane.mode = mode; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const s = try p.render(frame.allocator()); - for (0..rows) |row| for (0..cols) |col| { - const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - shot[i].text[row * cols + col] = cell.text; - shot[i].len[row * cols + col] = cell.len; - shot[i].style[row * cols + col] = cell.style; - }; - } - - var diffs: usize = 0; - for (0..rows) |row| { - const base = row * cols; - // The glyph run normal mode shows, and where it ends. - var last: ?usize = null; - for (0..cols) |col| { - if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col; - } - const end = last orelse continue; // blank row: nothing to align - - // Recover the shift: the first offset at which tty mode spells the same - // run. Zero for every row no prompt was hugged out of. - const shift = shift: { - var s: usize = 0; - while (s + end < cols) : (s += 1) { - var all = true; - for (0..end + 1) |col| { - if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) { - all = false; - break; - } - } - if (all) break :shift s; - } - var tty_row: [256]u8 = undefined; - var nrm_row: [256]u8 = undefined; - var tn: usize = 0; - var nn: usize = 0; - for (0..cols) |col| { - const tg = glyphAt(shot[0], base + col); - const ng = glyphAt(shot[1], base + col); - if (tn + tg.len < tty_row.len) { - @memcpy(tty_row[tn..][0..tg.len], tg); - tn += tg.len; - } - if (nn + ng.len < nrm_row.len) { - @memcpy(nrm_row[nn..][0..ng.len], ng); - nn += ng.len; - } - } - std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] }); - diffs += 1; - break :shift null; - } orelse continue; - - // Every column the shift can reach, not just the ones holding a glyph: - // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded - // table cell) carries colour too, and is exactly what a shell paints - // most of. - for (0..cols - shift) |col| { - const want = shot[0].style[base + col + shift]; - const got = shot[1].style[base + col]; - if (std.meta.eql(want, got)) continue; - if (diffs < 6) std.debug.print( - "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}", - .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul }, - ); - diffs += 1; - } - } - if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs }); - return diffs; -} - -test "a prompted session keeps every glyph's color in normal mode" { - const testing = std.testing; - const payload = - "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++ - "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++ - "\x1b[31merror: nope\x1b[0m\r\n" ++ - "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++ - "\x1b[38;5;208mhi\x1b[0m\r\n"; - - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); - const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off"); - try testing.expectEqual(@as(usize, 0), diffs); - } -} - -test "an emoji prompt neither eats the command nor slides its colors" { - const testing = std.testing; - // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this - // surface can BOTH be wrong about a cluster's width, and then a differential - // agrees with itself while the user sees the wrong thing. What is typed at - // the prompt is what must appear, each character wearing its own colour. - // - // Ghostty splits these clusters across cells and spells each one in the row - // dump, so the cell walk and the byte walk only agree if the byte walk is - // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧` - // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here. - const prompts = [_][]const u8{ - "plain", - "\u{1F468}\u{200D}\u{1F4BB}", // technologist - "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family - "\u{1F1FA}\u{1F1F8}", // flag - "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone - "\u{2764}\u{FE0F}", // heart, VS16 - "\u{0031}\u{FE0F}\u{20E3}", // keycap - "\u{754C}", // CJK wide - "e\u{301}", // NFD - }; - for (prompts) |prompt| { - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = .normal; - - var buf: [256]u8 = undefined; - const bytes = try std.fmt.bufPrint( - &buf, - "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m", - .{prompt}, - ); - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| { - const cell = s.at(tx + @as(u16, @intCast(i)), body_y); - testing.expectEqualStrings(want, cell.grapheme()) catch |err| { - std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i }); - return err; - }; - } - // `ab` was printed red and `cd` blue, so whatever the theme does - // with those two runs, the pair boundary has to fall between `b` - // and `c`. A prompt that cost the row a character shows up here as - // the boundary sliding onto the wrong glyph. - const fg = [_]pardes.Color{ - s.at(tx, body_y).style.fg, - s.at(tx + 1, body_y).style.fg, - s.at(tx + 2, body_y).style.fg, - s.at(tx + 3, body_y).style.fg, - }; - errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg }); - try testing.expect(std.meta.eql(fg[0], fg[1])); - try testing.expect(std.meta.eql(fg[2], fg[3])); - try testing.expect(!std.meta.eql(fg[1], fg[2])); - if (!filter) { - try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]); - try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]); - } - } - } -} - -test "background-only cells keep their color through the prompt hug" { - const testing = std.testing; - // Backgrounds with no glyph under them are most of what a shell paints: - // erase-to-end-of-line after a colour is set, padded table cells, and a - // selected row. They have no text to align on, so they are the cells a - // column translation is most likely to lose. - const payload = - "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++ - "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++ - "a\x1b[42m \x1b[0mb\r\n" ++ - "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++ - "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++ - "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n"; - - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); - const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off"); - try testing.expectEqual(@as(usize, 0), diffs); - } -} - -test "a leftover edit buffer does not move what tty mode shows" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - - for (0..60) |i| { - var buf: [40]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const rows: usize = r.h - pardes.BOX_H; - const cols: usize = r.w -| config.GUTTER; - - // What tty mode shows with nothing left behind: the reference. - p.shell_rows.stale = true; - const clean = try p.render(frame.allocator()); - const want_text = try testing.allocator.alloc([7]u8, rows * cols); - defer testing.allocator.free(want_text); - const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols); - defer testing.allocator.free(want_fg); - for (0..rows) |row| for (0..cols) |col| { - const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - want_text[row * cols + col] = cell.text; - want_fg[row * cols + col] = cell.style.fg; - }; - - // `enterTty` clears every other modal remnant but leaves the edit buffer, so - // a buffer whose covered span STRADDLES the viewport top is an ordinary - // state. tty mode does not apply the buffer, so it must not be moved by one - // either — and `surfRow`/`gridRow` are not inverses across that span. - const anchor = gridOffset(pane); - pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - - for (0..rows) |row| for (0..cols) |col| { - const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); - try testing.expectEqualStrings( - std.mem.sliceTo(&want_text[row * cols + col], 0), - std.mem.sliceTo(&cell.text, 0), - ); - try testing.expectEqual(want_fg[row * cols + col], cell.style.fg); - }; -} - -test "a background after a row-final wide glyph lands on the right columns" { - const testing = std.testing; - // A CJK glyph then a coloured erase-to-end-of-line, with a second colour - // partway. The glyph's grid tail spells no bytes, so the pairing walk used - // to stop ON it and pair every later column with the cell before it: an - // unpainted hole beside the glyph and every boundary one column right. - // - // ABSOLUTE assertions: both modes were wrong identically here, so a - // tty/normal differential says nothing. - for ([_]bool{ false, true }) |filter| { - for ([_]pardes.Mode{ .tty, .normal }) |mode| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = mode; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme()); - // The glyph covers columns 0-1; red runs from 2 up to the second - // erase at column 6 (1-based 7), blue from there to the edge. - const red = s.at(tx + 3, body_y).style.bg; - const blue = s.at(tx + 9, body_y).style.bg; - errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg }); - try testing.expect(!std.meta.eql(red, blue)); - for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); - for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); - } - } -} - -test "a colored row reaches its last column when a wide glyph did not fit" { - const testing = std.testing; - // Thirteen cells of red background, then a wide glyph with one column left: - // ghostty leaves a `spacer_head` in that last column, carrying the row's - // background, and wraps the glyph to the next row. A head OWNS its column, - // so skipping it the way a tail is skipped left the row's final column bare. - for ([_]pardes.Mode{ .tty, .normal }) |mode| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = mode; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - const red: pardes.Color = .{ .index = 1 }; - var c: u16 = 0; - while (c < r.w -| config.GUTTER) : (c += 1) { - errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg }); - try testing.expectEqual(red, s.at(tx + c, body_y).style.bg); - } - } -} - -test "tty colours survive a scrollback deeper than the pane" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - for (0..40) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const s = try p.render(frame.allocator()); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - var bad: usize = 0; - for (0..r.h -| pardes.BOX_H) |vr| { - var buf: [16]u8 = undefined; - var n: usize = 0; - for (0..10) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - if (g.len != 1) break; - buf[n] = g[0]; - n += 1; - } - const txt = buf[0..n]; - if (!std.mem.startsWith(u8, txt, "line-")) continue; - const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue; - const want = pardes.Color{ .index = @intCast(20 + num) }; - const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; - if (!std.meta.eql(want, got)) { - bad += 1; - std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got }); - } - } - try testing.expectEqual(@as(usize, 0), bad); -} - -test "reverse video swaps the default colors with the filter off too" { - const testing = std.testing; - // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to - // be honoured on BOTH colour paths. The theme filter folds it into its own - // palette; the raw path resolves a `.none` colour by role, and simply - // dropped reverse video altogether. - for ([_]bool{ false, true }) |filter| { - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = filter; - pane.mode = .normal; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } }); - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - const plain = before.at(tx, body_y).style; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - const reversed = after.at(tx, body_y).style; - - errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg }); - try testing.expectEqual(plain.fg, reversed.bg); - try testing.expectEqual(plain.bg, reversed.fg); - } -} - -test "untouched lines between two edits keep their colors" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - for (0..6) |i| { - var buf: [40]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - // The state two ordinary edits reach: one at the bottom, one that split a - // line further up. The buffer now spans rows 2..6 and diverges at BOTH - // ends, with three untouched lines in the middle. Matching a leading and a - // trailing run stops at the first divergence and drains exactly those three; - // each line carries its own evidence, so each is anchored on its own. - pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05` - for (0..3) |k| { - const vr = @as(u16, @intCast(4 + k)); - var buf: [8]u8 = undefined; - const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable; - const cell = s.at(tx, body_y + vr); - errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg }); - try testing.expectEqualStrings(want_text[0..1], cell.grapheme()); - try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg); - } -} - -test "a prompt row hidden end to end paints nothing at all" { - const testing = std.testing; - // The command's first glyph is wide with one column left, so ghostty leaves - // a spacer_head carrying the command's background and wraps the glyph to - // the next row. `promptRow` renders this row EMPTY, so no cell of it may - // take a colour — a spacer owns no column of its own. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); - try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg)); -} - -test "an emptied edit buffer does not shift the colors below it" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - // The state three keystrokes reach on any blank shell row: type a character - // and delete it, and the buffer holds NO text while still standing in for - // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one - // line, so anything deriving the slide from the former puts every colour - // below here one row too far down — and drops the bottom row's entirely. - pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") }; - p.shell_rows.stale = true; - const s = try p.render(frame.allocator()); - - try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme()); - try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme()); - try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg); - try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg); - // ...and the user's own empty line takes no colour from the row beneath it - try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg)); -} - -test "an edit overlay never changes tty-mode ansi colors" { - const testing = std.testing; - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .tty; - - p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const blue: pardes.Color = .{ .index = 4 }; - - p.shell_rows.stale = true; - const before = try p.render(frame.allocator()); - try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); - - // A lingering multi-line edit overlay must not move any shell row's colour. - pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; - p.shell_rows.stale = true; - _ = frame.reset(.retain_capacity); - const after = try p.render(frame.allocator()); - try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); - try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); -} - -pub fn palColor(p: *Pardes, idx: u8) pardes.Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; -} - -pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| palColor(p, idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; -} - -/// executing at a prompt with typed text below it: pad the output area -/// with newlines so the command's output doesn't overwrite the buffer -pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { - // Nothing to pad away from: with no emulator there is no prompt and no - // child whose output could land on top of the edit buffer. - if (comptime !enabled) return; - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - if (!pane.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); -} - -/// the snapshot takes ownership of a COPY of the edit buffer's text -pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot { - var ovl: ?EditBuffer = null; - if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; - return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; -} - -/// undo/redo restores the selection recorded with the snapshot (helix -/// keeps selections in its history transactions) -pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void { - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = s.ovl; - pane.cur_row = s.cur_row; - pane.cur_col = s.cur_col; - pane.cur_pinned = true; - pane.vsel = s.vsel; - pane.msel.active = false; - pane.ensureCursorVisible(); -} - -fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void { - if (len.* == slots.len) { - if (slots[0].ovl) |overlay| gpa.free(overlay.text); - std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]); - len.* -= 1; - } - slots[len.*] = value; - len.* += 1; -} - -pub fn pushUndo(p: *Pardes, pane: *Pane) void { - const current = pane.ovl orelse EditBuffer{ .rows = 0 }; - if (pane.ed_undo_len > 0) { - const top = pane.ed_undo[pane.ed_undo_len - 1]; - const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and - overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null; - if (same) return; - } - const value = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value); - for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text); - pane.ed_redo_len = 0; -} - -pub fn undo(p: *Pardes, pane: *Pane) void { - if (pane.ed_undo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current); - pane.ed_undo_len -= 1; - restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); -} - -pub fn redo(p: *Pardes, pane: *Pane) void { - if (pane.ed_redo_len == 0) return; - const current = snap(p, pane) orelse return; - pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current); - pane.ed_redo_len -= 1; - restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); -} - -// ghostty calls this with a reply (cursor-position report, DA, ...) to send -// back to the child as if it typed it. The handler's `terminal` is our Pane.vt -// field; recover the Pane and stash the bytes — sync() drains them into write -// effects (the callback has no path to the effect queue). -pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal)); - const room = pane.reply.len - pane.reply_len; - const n = @min(room, data.len); - @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); - pane.reply_len += @intCast(n); -} - -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} - -test "an edited row keeps the colours of the bytes the edit did not touch" { - const testing = std.testing; - // The loudest colour bug this editor had: one keystroke anywhere in a - // coloured row turned EVERY column of it grey, because an anchor was all or - // nothing. The row's own bytes survive at both ends of what was typed, and - // being the same bytes they keep the same colours; only the typed character - // has no cell under it and so takes none. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..6) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - // One `Z` typed into the middle of row 3's own text. - pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - const want = pardes.Color{ .index = 33 }; - var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [15]u8 = undefined; - for (0..15) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue; - seen = true; - for (0..15) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got }); - // Column 9 is the typed `Z`; every other column is row 3's own. - if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got); - } - } - try testing.expect(seen); -} - -test "joining two rows leaves the rows below them their colours" { - const testing = std.testing; - // A join removes a buffer line while the buffer's covered span GROWS, so the - // two counts cancel at `lines == covered`. Anchoring that only counts down - // from the buffer's top and up from its bottom then resolves both ways to - // the SAME row, one short of where the lines below live, and every untouched - // row under the join went plain. This is the state four keystrokes reach - // (Enter, then a backspace two rows up), taken from the fuzzer that found it. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - pane.ovl = .{ - .row = 23, - .rows = 4, - .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"), - }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - var seen = false; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [12]u8 = undefined; - for (0..12) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue; - seen = true; - // The join is above it and its own text is untouched, so every column - // still carries row 25's own colour. - for (0..12) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got }); - try testing.expectEqual(pardes.Color{ .index = 45 }, got); - } - } - try testing.expect(seen); -} - -test "an untouched row always carries the colour its own text names" { - const testing = std.testing; - // Random editing, absolute oracle: every row's own text names the colour it - // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing - // anything else. This is what found the join above, and the empty line that - // claimed a blank row far below it and took every coloured row in between - // out of reach of the lines that owned them. - var seed: u64 = 0; - while (seed < 40) : (seed += 1) { - var prng = std.Random.DefaultPrng.init(seed); - const rand = prng.random(); - - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - // The raw palette, so a row's text names its exact colour instead of one - // this test would have to re-derive from the theme. - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - const body_h = r.h -| pardes.BOX_H; - - var step: usize = 0; - while (step < 12) : (step += 1) { - _ = frame.reset(.retain_capacity); - const s = try p.render(frame.allocator()); - for (0..body_h) |vr| { - var buf: [24]u8 = undefined; - for (0..24) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - const txt = std.mem.trimEnd(u8, buf[0..24], " "); - if (txt.len != 12) continue; - if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue; - const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue; - const want = pardes.Color{ .index = @intCast(20 + num) }; - for (0..txt.len) |c| { - const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got }); - try testing.expectEqual(want, got); - } - } - - switch (rand.intRangeAtMost(u8, 0, 10)) { - 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }), - 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }), - 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }), - 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }), - 4 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 5 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.enter } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 6 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.backspace } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 7 => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); - p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }), - 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }), - else => { - p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); - p.update(.{ .key = .{ .cp = pardes.Key.delete } }); - p.update(.{ .key = .{ .cp = pardes.Key.escape } }); - }, - } - while (p.nextEffect()) |_| {} - } - } -} - -test "a new empty line does not take the colours of the rows below it" { - const testing = std.testing; - // Splitting a row makes an EMPTY buffer line, and empty equals every blank - // row in the buffer's span - including the one under the last output. Left - // free to look ahead for a row spelling the same bytes, that line claimed - // the blank row far below and put every coloured row in between out of - // reach of the lines that owned them. Two keystrokes (Home, Enter) got here. - const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = p.panes[0].?; - pane.tty_filter = false; - pane.mode = .normal; - for (0..26) |i| { - var buf: [64]u8 = undefined; - const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; - p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); - } - // A newline typed at column 0 of row 24, and `WW` typed on the blank row - // below the output: the span covers rows 24, 25 and that blank row. - pane.ovl = .{ - .row = 24, - .rows = 3, - .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"), - }; - p.shell_rows.stale = true; - - var frame = std.heap.ArenaAllocator.init(testing.allocator); - defer frame.deinit(); - const r = p.rects[0]; - const tx = r.x + config.GUTTER; - const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - - const s = try p.render(frame.allocator()); - var seen: usize = 0; - for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { - var buf: [12]u8 = undefined; - for (0..12) |c| { - const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); - buf[c] = if (g.len == 1) g[0] else '?'; - } - if (!std.mem.startsWith(u8, &buf, "row-2")) continue; - const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue; - if (num != 24 and num != 25) continue; - seen += 1; - const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; - errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got }); - try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got); - } - try testing.expectEqual(@as(usize, 2), seen); -} diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 2ca0263e..9363841a 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -7,10 +7,10 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); -const panel_animation = @import("../panel_animation.zig"); +const layout = @import("../layout.zig"); -const Box = panel_animation.Box; -const Track = panel_animation.Track; +const Box = layout.Box; +const Track = layout.Track; /// Kitty placements cannot be resampled through the character-grid transform. /// Keep their transmitted pixels cached, but omit the placement while its pane @@ -77,7 +77,7 @@ pub fn compose( // Stable layout motion first, newly opening panels above it, and closing // tombstones last. A closing pane no longer owns input or canonical cells, // but its frozen old content remains the top visual until it slides out. - for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!drawable(source, track) or track.phase != phase) continue; switch (track.effect) { @@ -139,7 +139,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v var x = area.x0; while (x < area.x1) : (x += 1) { if (!source.panelCellChanged(x, y)) continue; - if (panel_animation.dissolveRevealed( + if (layout.dissolveRevealed( track.serial, x - area.x0, y - area.y0, @@ -339,7 +339,7 @@ test "TTY content effects never touch cells outside the published diff" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .ascii, .dissolve }) |effect| { + for ([_]layout.Transition{ .ascii, .dissolve }) |effect| { const track: Track = .{ .serial = 17, .effect = effect, @@ -366,7 +366,7 @@ test "content transition without a diff snaps to canonical surface" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]panel_animation.Transition{ .dissolve, .ascii, .vertical }) |effect| { + for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| { const track: Track = .{ .effect = effect, .phase = .opening, @@ -391,7 +391,7 @@ test "exact transition endpoint preserves the canonical cursor" { }; const track: Track = .{ .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = .{ .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; @@ -431,7 +431,7 @@ test "vertical opening rises through a fixed old-grid clip" { .serial = 5, .phase = .opening, .effect = .vertical, - .from = panel_animation.openingBox(.vertical, target, 3), + .from = layout.openingBox(.vertical, target, 3), .to = target, }; @@ -485,7 +485,7 @@ test "vertical closing drops frozen content over canonical cells" { .phase = .closing, .effect = .vertical, .from = old_box, - .to = panel_animation.closingBox(.vertical, old_box), + .to = layout.closingBox(.vertical, old_box), }; const first = try compose(arena.allocator(), &surface, &.{track}, null); @@ -530,13 +530,13 @@ test "closing content paints after opening content regardless of track order" { .phase = .closing, .effect = .vertical, .from = box, - .to = panel_animation.closingBox(.vertical, box), + .to = layout.closingBox(.vertical, box), }; const opening: Track = .{ .serial = 2, .phase = .opening, .effect = .ascii, - .frame = panel_animation.Transition.ascii.frames() - 1, + .frame = layout.Transition.ascii.frames() - 1, .from = box, .to = box, }; @@ -691,8 +691,8 @@ test "active panel transition hides only its own native attachment" { const tracks = [_]Track{ .{ .serial = 41, .effect = .slide, .frame = 0 }, .{ .serial = 42, .effect = .ascii, .frame = 0 }, - .{ .serial = 43, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() - 1 }, - .{ .serial = 44, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() }, + .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 }, + .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() }, .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 }, }; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index fb0a5b8e..1f11c535 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1,12 +1,4 @@ -//! The terminal shell: owns the event loop and all IO. Translates vaxis -//! events into core events, performs the core's effects (fork ptys, write -//! them, resize them), and hands the core's Surface to vaxis cell-for-cell — -//! the canonical interface rendered with no interpretation. -//! -//! It also holds the OTHER loop a terminal can run: an attached frontend, -//! which has a socket where its core would be and performs no machine-local -//! effect whatsoever (see `Attach`). The `Attach` builtin turns the first into -//! the second in place, without giving up the terminal. +const filesystem = @import("../fs.zig"); const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; @@ -15,19 +7,11 @@ const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); -const shell_bin = @import("../shell_bin.zig"); -const message = @import("../message.zig"); +const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); -const user_config = @import("../user_config.zig"); const selection_pipe = @import("../selection_pipe.zig"); -const nested = @import("../nested.zig"); -const fuse = @import("../fuse.zig"); -const fs_service = @import("../fs_service.zig"); +const ninep_io = @import("../9p_io.zig"); const panel_compositor = @import("panel_compositor.zig"); -const host_api = @import("../host.zig"); -// The other half of `--detach`, and the reason this file has an attached loop -// at all: the frontend side of a detached session is a terminal and a socket, -// and this file is already the one that owns a terminal. const detached_client = @import("../detached/client.zig"); const detached_server = @import("../detached/server.zig"); const wire = @import("../detached/wire.zig"); @@ -35,7 +19,6 @@ const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); pub const Command = struct { @@ -44,54 +27,231 @@ pub const Command = struct { quit, tick, key_press: vaxis.Key, - pty_read: struct { id: usize, bytes: []u8 }, + pty_read: struct { id: usize, gen: u32, bytes: []u8 }, pty_eof: struct { id: usize, gen: u32 }, winsize: vaxis.Winsize, mouse: vaxis.Mouse, - /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY - /// cannot report a literal pointer crossing its character grid, so - /// losing terminal focus is its only reliable pointer-leave signal. focus_in, focus_out, paste: []const u8, - /// The bracketed-paste brackets. vaxis posts them ONLY because this - /// union declares fields with these exact names — its Loop gates every - /// event on `@hasField` — and the pasted bytes themselves arrive - /// BETWEEN them as ordinary key presses, which the loop accumulates - /// into one `.paste` above instead of running as commands. paste_start, paste_end, - /// a language query finished on a worker; rows are lsp-domain-owned - lsp_done: struct { id: u32, rows: []u8 }, - /// a language SERVER changed state (spawned, indexing, exited) — the - /// client's reader thread narrates and this lands it on the message - /// row; text is lsp-domain-owned + lsp_done: struct { id: u32, rows: ?[]u8 }, lsp_status: []u8, - /// a selection-filter worker finished; every stdout is gpa-owned pipe_done: selection_pipe.Response, - /// something happened in a watched directory (see watchFiles) files_changed, - /// a pardes launched inside this one sent us a builtin command line - /// (see lookServer); gpa-owned, like pty_read - command: []u8, - /// `--fs`: the /dev/fuse descriptor has requests on it. Carries - /// nothing and is applied as a no-op — its whole job is to end the - /// blocking `nextEvent`, because the drain itself lives in pollFrame - /// beside the file-watch reload. Same shape and same reason as - /// `files_changed` above, and posted from two places: the poll thread - /// when the kernel makes the descriptor readable, and pollFrame itself - /// when a batch hit its cap with requests still pending. fs_ready, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); -/// The shared snapshot/worker pair every native shell uses. This file used to -/// carry its own `LspJob` and gui.zig carried a copy of it; the copies said so. -const lsp_host = @import("../lsp_host.zig"); +fn startInput(loop: *Loop, cache: *vaxis.GraphemeCache) !void { + if (comptime builtin.os.tag == .windows) return loop.start(); + if (loop.thread != null) return; + loop.thread = try loop.io.concurrent(inputThread, .{ loop, cache }); +} + +fn stopInput(loop: *Loop) void { + if (comptime builtin.os.tag == .windows) return loop.stop(); + if (loop.thread) |*thread| { + thread.cancel(loop.io); + loop.thread = null; + } +} + +fn inputThread(loop: *Loop, cache: *vaxis.GraphemeCache) void { + inputReader(loop, loop.tty, cache); +} + +fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void { + readInput(loop, tty, cache) catch |err| { + if (err == error.Canceled) return; + std.log.err("terminal input: {s}", .{@errorName(err)}); + }; + loop.postEvent(.quit) catch {}; +} + +fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { + try loop.postEvent(.{ .winsize = try tty.getWinsize() }); + var parser: vaxis.Parser = .{}; + var buf: [1024]u8 = undefined; + var carried: usize = 0; + while (!loop.should_quit) { + if (carried == buf.len) return error.InputSequenceTooLong; + const received = try tty.read(buf[carried..]); + if (received == 0) return; + const end = carried + received; + var parse_end = end; + var lead = end; + while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1; + if (lead > 0) { + const scalar_len = std.unicode.utf8ByteSequenceLength(buf[lead - 1]) catch 1; + if (scalar_len > end - (lead - 1)) parse_end = lead - 1; + } + var consumed: usize = 0; + while (consumed < parse_end) { + const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); + if (result.n == 0) break; + consumed += result.n; + if (result.event) |event| + vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| { + if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); + return err; + }; + } + carried = end - consumed; + std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); + } +} + +test "terminal input preserves fragmented keys queries paste and text after EOF" { + 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, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ .parts = &.{ + "plain \x1b[?62;", "4c\x1b[", "A\x1b[200", "~caf\xc3", "\xa9 \xe7", "\x95", + "\x8c \xf0\x9f", "\x98\x80", "\x1b[201", "~\x1b]52;c;Y2", "xpcA==\x07tail", "\x1b", + } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(vx.queries_done.load(.unordered)); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + var resized = false; + var up = false; + var in_paste = false; + var pasted = false; + var clipboard = false; + var escape = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => |size| { + try std.testing.expect(!resized); + resized = true; + try std.testing.expectEqual(@as(u16, 80), size.cols); + }, + .key_press => |key| { + try std.testing.expect(resized); + if (key.codepoint == vaxis.Key.up) { + up = true; + } else if (key.codepoint == vaxis.Key.escape) { + escape = true; + } else if (key.text) |bytes| { + if (in_paste) try std.testing.expect(up); + try text.appendSlice(gpa, bytes); + } else return error.UnexpectedInputKey; + }, + .paste_start => { + try std.testing.expect(up and !in_paste); + in_paste = true; + }, + .paste_end => { + try std.testing.expect(in_paste); + in_paste = false; + pasted = true; + }, + .paste => |bytes| { + defer gpa.free(@constCast(bytes)); + try std.testing.expect(pasted and !in_paste); + try std.testing.expectEqualStrings("clip", bytes); + clipboard = true; + }, + else => return error.UnexpectedInputEvent, + }; + try std.testing.expectEqualStrings("plain café 界 😀tail", text.items); + try std.testing.expect(resized and up and pasted and clipboard and escape); +} + +test "terminal input cancellation joins blocked reads and queued EOF" { + 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); + const Reader = struct { + file: std.Io.File, + eof: bool, + entered: std.Io.Event = .unset, + release: std.Io.Event = .unset, + returned: std.Io.Event = .unset, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + self.entered.set(std.testing.io); + if (self.eof) { + try self.release.wait(std.testing.io); + self.returned.set(std.testing.io); + return 0; + } + return self.file.readStreaming(std.testing.io, &.{buf}); + } + fn run(loop: *Loop, reader: *@This(), cache: *vaxis.GraphemeCache) void { + inputReader(loop, reader, cache); + } + }; + const Case = enum { blocked_read, cancel_eof, deliver_eof }; + for (std.enums.values(Case)) |case| { + const eof = case != .blocked_read; + var fds: [2]c_int = undefined; + if (libc.pipe(&fds) != 0) return error.PipeFailed; + defer _ = libc.close(fds[0]); + defer _ = libc.close(fds[1]); + var reader: Reader = .{ .file = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }, .eof = eof }; + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + loop.thread = try io.concurrent(Reader.run, .{ &loop, &reader, &cache }); + defer stopInput(&loop); + try std.testing.expectEqual(.winsize, std.meta.activeTag(try loop.nextEvent())); + try reader.entered.wait(io); + if (eof) { + for (0..512) |_| try loop.postEvent(.nop); + reader.release.set(io); + try reader.returned.wait(io); + } + if (case == .deliver_eof) { + try std.testing.expectEqual(.nop, std.meta.activeTag(try loop.nextEvent())); + loop.thread.?.await(io); + } + stopInput(&loop); + try std.testing.expect(loop.thread == null); + var count: usize = 0; + while (try loop.tryEvent()) |event| { + const expected: std.meta.Tag(@TypeOf(Command.value)) = if (case == .deliver_eof and count == 511) .quit else .nop; + try std.testing.expectEqual(expected, std.meta.activeTag(event)); + count += 1; + } + try std.testing.expectEqual(@as(usize, if (eof) 512 else 0), count); + } +} -/// The pipe in-flight set moved to `selection_pipe.Tasks`, beside the Job it -/// tracks: gui.zig carried this same table verbatim. const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; @@ -125,9 +285,6 @@ fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u } }); } -/// Translate backend-neutral source/destination pixels into Kitty's source -/// crop plus cell-sized placement. Kitty can specify only one scaled axis -/// without distorting the image; the terminal derives the other axis. fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, @@ -151,13 +308,6 @@ fn kittyPlacement( place.native.pan_y, ) orelse return null; - // Kitty has a top-left pixel offset but no destination bottom clip. - // Its missing c/r axis is rounded up to whole terminal cells, so a - // clipped fragment shorter than one row cannot be represented without - // painting the following theme gap. Conservatively keep only whole - // rows contained by geometry.dst and trim the source crop to the same - // scale. Cached page pixels remain unchanged; an unrepresentable tail - // is simply left as theme background. const safe_rows_u32 = geometry.dst.h / cell_h; if (safe_rows_u32 == 0) return null; const safe_pixel_h = safe_rows_u32 * cell_h; @@ -202,8 +352,6 @@ fn kittyPlacement( } if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; - // Every Kitty protocol field is u16. Reject an attachment which the - // wire format cannot represent instead of truncating it. const src_x = std.math.cast(u16, geometry.src.x) orelse return null; const src_y = std.math.cast(u16, geometry.src.y) orelse return null; const src_w = std.math.cast(u16, geometry.src.w) orelse return null; @@ -287,8 +435,6 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= height.native.geometry.?.dst.h); - // There is no honest APC for less than one physical row: omitting it is - // preferable to painting three pixels of the following theme gap. height.native.geometry.?.dst.h = 13; try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); } @@ -304,8 +450,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa); defer gpa.free(replacement); try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original }); - // Prepare both editor-style temporary inodes before marking the directory - // so the only post-arm wake below is the second rename. try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement }); try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original }); var path_buf: [256]u8 = undefined; @@ -328,15 +472,12 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { defer core.deinit(); try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count); - // The core opened the three-page inode, but the host has not drained its - // watch effect yet. Replace it now: install-then-reconcile must discover - // the one-page document even though no source existed for this first edge. try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == 0 and watch.on) { armed = true; - try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true)); + try std.testing.expect(!file_watch.applyEffect(core, io, fd, &watches, 0, true, watch.mode)); }, else => {}, }; @@ -346,8 +487,6 @@ test "host watch closes initial race and reloads rename-over PDF while idle" { try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io); try std.testing.expect(file_watch.drain(fd)); - // This is the same pass the watcher thread schedules; no key, mouse, or - // synthetic core file_changed event participates in the transaction. try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches)); const pane = core.panes[0].?; try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); @@ -382,11 +521,6 @@ fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) const PdfWheelTarget = struct { pane: usize, page: usize }; -/// A native PDF's page geometry is invalid between `setPdfPage` and the next -/// render. Remember the page under a vertical wheel press so the input batch -/// can stop exactly when that press crosses a page boundary. The following -/// queued wheel report then sees the newly rastered page instead of treating -/// missing geometry as another page-wise fallback. fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget { if (comptime !pardes.pdf_enabled) return null; if (!core.native_images or mouse.type != .press or @@ -405,37 +539,17 @@ fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWhee return null; } -/// `attach` is `--attach[=]`: empty means "the session there is" (see -/// `detached_client.resolve`). It is a parameter rather than an `Options` field -/// because it says nothing to the core — this process does not have one when -/// it is set. pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; - // `--attach` only, and registered HERE — before the terminal is opened — - // for the LIFO: it must run after every deferred restore below. Why a - // frontend stopped is discovered deep inside the loop while the alt screen - // is still up, and anything written there is erased by the switch back to - // the main screen, which is the one screen a user would look at. var attach_end: AttachEnd = .none; defer attach_end.report(io); - // ...and resolved before the terminal too, for the same reason turned the - // other way: "no session called work" is a launch that never started, and - // flashing the alt screen up and straight back down to say so is worse - // than never entering it. Only the QUESTION is asked here, and asked - // through client.zig because the SDL frontend asks the identical one: - // `detached_client.attempt` asks it again with the socket in hand, and a - // session that ends between the two answers is a `.no_session` from there - // rather than a disagreement between two spellings of the same scan. var name_buf: [detached_server.path_max]u8 = undefined; var attach_name: []const u8 = &.{}; if (attach) |requested| switch (detached_client.resolve(&name_buf, requested)) { .name => |resolved| attach_name = resolved, - // Two ends for the union's one arm, because the advice differs: a name - // that resolved to nothing is a typo to correct, and no name at all is - // a session to start. .none => { attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached; return; @@ -446,16 +560,6 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v }, }; - // SIGWINCH must never run vaxis's signal handler: it posts the winsize - // event through std.Io.Mutex/Condition, and when the signal lands on a - // thread blocked inside an Io.Threaded syscall region (pty readers in - // read(2), the main thread parked in queue.pop) a contended lock re-enters - // the Io machinery and Syscall.start hits `unreachable` — panic, then the - // panic-time terminal restore used to write through the same Io and - // recurse until stack overflow. Reproduced by resizing the outer terminal - // (e.g. a font-size change) while shells run. Block it here, before any - // thread exists (threads inherit the mask, so vaxis's handler never - // fires), and take it synchronously on the sigwait thread below instead. var winch_set = posix.sigemptyset(); posix.sigaddset(&winch_set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); @@ -467,37 +571,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v defer vx.deinit(gpa, tty.writer()); try vx.enterAltScreen(tty.writer()); defer vx.exitAltScreen(tty.writer()) catch {}; - // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so - // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops - // middle press+release BEFORE mouse reporting when the desktop sets - // gtk-enable-primary-paste=false — no mode we request can surface middle - // clicks there (see test/snapshots/ghostty-mid.snap). try vx.setMouseMode(tty.writer(), true); - // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a - // flood of key presses: plausible-looking in insert mode, and in normal - // mode every pasted character runs as a command. With it the terminal - // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. - // No defer to switch it back off, for the same reason the mouse modes - // above have none: setBracketedPaste records state.bracketed_paste, and - // vaxis's resetState — reached from the `defer vx.deinit` above, while the - // tty is still open — sends the disable off that flag. try vx.setBracketedPaste(tty.writer(), true); - // `--attach`: this process has a terminal and NO core. Everything above is - // the terminal, which an attached frontend needs exactly as much as a whole - // session does; everything below is the core, which lives in the detached - // process. The branch is here so both leave by the same door — an attach - // has to restore cooked mode, the main screen and the mouse the way an - // ordinary exit does, and sharing the deferred teardown is the only way to - // guarantee that instead of asserting it. if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); return; } - // Everything below is the TERMINAL's, shared by the two loops that can draw - // on it: the local session's and, after an `Attach`, an attached one's. The - // core and everything that only a core needs is `localSession`'s. var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); defer { clearNativeImages(&kitty_handles, &vx, &tty); @@ -506,30 +587,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); + var input_cache: vaxis.GraphemeCache = .{}; - // How a connected client leaves `localSession`, and the whole of the - // handover: it is set only once `detached_client.attempt` has come back - // GREETED, so every way of failing to attach leaves the local session - // running with this still null. By the time `localSession` returns non-null - // its scope has ended, which means every pane shell, watch, worker and - // mount of the local session is already away — the teardown is a scope - // exit rather than a second copy of the same defers. var attached: ?detached_client.Client = null; - // The loop is STARTED inside `localSession`, because the initial forkpty - // has to happen before any thread of ours exists, and stopped by whichever - // loop was the last to use it: `localSession` itself when it is exiting for - // good, and this defer when it handed the terminal on. Registered before - // the call so LIFO puts the drain after `loop.stop()` — vaxis's reader must - // be joined before the queue is emptied, or a late post lands in a queue - // nobody drains again and its bytes leak. defer if (attached != null) { - loop.stop(); + stopInput(&loop); drainAttachedQueue(&loop, gpa); }; - try localSession(init, opts, &tty, &vx, &loop, &kitty_handles, &paste_buf, &attached); + try localSession(init, opts, &tty, &vx, &loop, &input_cache, &kitty_handles, &paste_buf, &attached); if (attached) |*client| { - // `detach` and not `deinit`: seven bytes that turn "the peer vanished" - // into "the peer left" in the session's log. defer client.detach(); var a: Attach = .{ .gpa = gpa, @@ -537,43 +603,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v .loop = &loop, .vx = &vx, .tty = &tty, - // The `Shell`'s own paste buffer. Nothing is in flight in it: a - // bracketed burst cannot span the switch, because the builtin that - // caused the switch was a keystroke, and every `paste_start` clears - // it before it fills. .paste_buf = &paste_buf, - // `caps_pending` deliberately keeps its default rather than - // inheriting the local session's: `enableDetectedFeatures` is - // idempotent mode-setting, and the `queueRefresh` it pairs with is - // wanted anyway on a screen that just changed which core draws it. - // `session` keeps its empty default for a reason worth stating: the - // name the `Attach` word carried lived in the core's own - // `attach_buf`, and that core is deinited by the time this runs. A - // later `Detach` therefore says "that session" rather than naming - // it, which is also all a bare `Attach` ever said. }; attach_end = attachLoop(&a); } } -/// The session that lives in THIS process: the core, its pane shells, its -/// watches, its acme filesystem, its workers and the loop that pumps them. A -/// function of its own rather than the tail of `run` because that makes its -/// teardown a SCOPE EXIT instead of a second copy of the same nine defers — -/// and the `Attach` builtin needs exactly that teardown, in exactly that LIFO -/// order, before an attached loop may draw on the same terminal. The hand-copy -/// it replaces had 29 lines identical to these defers and stated its ordering -/// contract in prose, so nothing but a reader could enforce it. -/// -/// The terminal itself is NOT here: `tty`, `vx`, the alt screen, the `Loop`, -/// the paste buffer and the kitty placements outlive this scope because the -/// attached loop keeps drawing on them. fn localSession( init: std.process.Init, opts: pardes.Options, tty: *vaxis.Tty, vx: *vaxis.Vaxis, loop: *Loop, + input_cache: *vaxis.GraphemeCache, kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), paste_buf: *std.Io.Writer.Allocating, attached: *?detached_client.Client, @@ -581,13 +623,12 @@ fn localSession( const io = init.io; const gpa = init.gpa; - const allocs = pardes.allocators.init(gpa); - defer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + defer pardes.memory.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; - // the 16 MiB static buffer behind every per-frame Surface options.frame_allocator = allocs.frame; pardes.image.start(io, allocs.image); @@ -600,37 +641,20 @@ fn localSession( } var core = if (options.load_path) |lp| blk: { - const bytes = try look.readFile(gpa, lp); + const bytes = try filesystem.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); - // PATH, the bash banner and the prompt rc files, in the one order that - // works. Children borrow only these stable in-struct path buffers. - var prompt_rcs = shell_bin.prepareForFork(); + var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); - // `--fs`: mount before the initial spawns, because those shells are the - // ones that need PARDES_FS in their environment, and before the first - // frame, because a script racing startup must find panes that are already - // there. Also before any thread of ours exists — the mount forks the - // setuid fusermount3 helper, and forking from a multithreaded process is - // the hazard this whole region is ordered around. Null covers both "no - // --fs" and "--fs but the mount failed"; the second is reported on a - // message row inside `start` and the session runs on regardless. - // - // The teardown answers every held request, aborts the connection, - // unmounts and removes `/`. The PARENT (`.../pardes`) stays, - // like nested.zig's socket directory: another session may be living in it, - // and rmdir of a shared directory is not ours to attempt. - var fs = fs_service.start(gpa, core); - // Covers the error paths and the handover; the ordinary exit unmounts at - // the END OF THE LOOP instead, see there. - defer if (fs) |f| f.deinit(); + var fs = ninep_io.start(gpa, core); + defer if (fs) |f| f.deinit(gpa); var sh: Shell = .{ .io = io, @@ -644,57 +668,26 @@ fn localSession( .kitty = kitty_handles, .frame = &frame_arena, .paste_buf = paste_buf, - // One watcher for every watched pane, opened here — before any thread - // exists — so the pre-loop effect drain below can already mark the file - // a positional path argument opened. `false`: this host parks a thread - // in it rather than polling it. -1 where there is no watcher to make: - // watchPane goes quiet and the core simply never gets a file_changed. .inotify_fd = file_watch.init(false), .fs = fs, }; - // The protocol client's reader threads narrate server state through this - // sink from the moment it is set; posting is safe because the loop queue - // outlives them all — and it is UNSET first thing in the defer below, - // under the sink's own lock, so no reader can be mid-post when the queue - // starts draining for teardown. pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { pardes.lsp.setStatusSink(null, null); - // reap the reader tasks (cancel interrupts a blocked read) before - // closing the masters — the runtime joins those threads on exit and a - // reader stuck in read(2) would hang the process — then drain the - // queue: leftover events own gpa bytes and would dump as leaks. for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); - // THE ONE DELTA BETWEEN THE TWO WAYS OUT OF THIS SCOPE, and the - // reason it is a condition rather than a comment: an exit leaves - // the closed master's SIGHUP to kill the shell and the kernel to - // collect it, which server.zig's `harvest` calls "the one - // bookkeeping cost a long-lived process pays that a frontend, - // which exits, never did". A handover does not exit — this process - // goes on drawing somebody else's session for hours — so a skipped - // `waitpid` is a zombie per pane held for all of it. SIGKILL and - // not the hangup alone because the wait has to be BOUNDED: the - // master is gone, so there is nothing left for the shell to print - // and no graceful exit left to give it, and SIGHUP is a signal it - // may decline while SIGKILL is not. if (attached.* != null) { _ = libc.kill(pt.pid, posix.SIG.KILL); _ = libc.waitpid(pt.pid, null, 0); } slot.* = null; }; - // join the query worker BEFORE the drain below, or its late post - // lands in a queue nobody empties again and the rows leak if (sh.lsp_task) |*t| { - t.cancel(io) catch {}; + t.future.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); - // same contract as the pty readers: the watcher has to be off the - // descriptor before it is closed. `stop` is what releases a kqueue wait - // (macos); `cancel` is what interrupts the blocking read (linux). file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; @@ -706,9 +699,8 @@ fn localSession( } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), - .command => |line| gpa.free(line), .paste => |b| gpa.free(@constCast(b)), - .lsp_done => |d| allocs.lsp.free(d.rows), + .lsp_done => |d| if (d.rows) |rows| allocs.lsp.free(rows), .lsp_status => |text| allocs.lsp.free(text), .pipe_done => |response_value| { var response = response_value; @@ -719,114 +711,44 @@ fn localSession( } const host = sh.host(); - // The socket a pardes launched inside this one connects to (nested.zig). - // Declared AFTER the drain above so its teardown runs BEFORE it — the - // listener thread must be out of the way before the queue is emptied. - // --nested opted out of the whole mechanism, including being an outer - // instance; so does any failure to bind, and then children simply open - // their own session. - const sock_fd: c_int = if (options.nested) -1 else nested.listen(); - defer nested.unlisten(sock_fd); - - // Perform the initial spawns BEFORE any worker thread exists: forkpty from - // a multithreaded process can wedge the child before exec. `pump` installs - // the host on every pass; this drain runs outside it, so install it here. core.host = host; while (core.nextEffect()) |effect| core.perform(effect); - try loop.start(); - // ...and stopped here only when this scope is the last user of the - // terminal. A handover leaves vaxis's reader running for the attached loop, - // which is drawing on the same tty a moment later; `run` stops it then. - defer if (attached.* == null) loop.stop(); - // resize watcher: plain detached thread (not io.concurrent — teardown - // joins those, and sigwait never returns); dies with the process + try startInput(loop, input_cache); + defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); - // ...and the nested-instance listener, detached for the same reason: a - // blocking accept(2) never returns either, so an io.concurrent task would - // hang the teardown that joins it. - if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, loop })).detach(); - // ...and the /dev/fuse poller, which is the same kind of thread again: it - // waits for POLLIN and posts, never touching the core or the descriptor's - // data. Joined by `Fs.deinit` rather than detached, because unlike accept4 - // it CAN be woken — fuse.zig gives it a control pipe for exactly that. - fs_service.wake(fs, loop, wakeFs); - // Capability handshake — SEND the probes, do not wait on them. This was - // queryTerminal(2ms), which blocks on a futex until DA1 comes back. The - // number has to beat one terminal round trip: a local terminal answers in - // microseconds, `ssh localhost` in under 1ms, and any real link never. - // Measured over sshd on :22 with the replies delayed to model the wire, - // 2ms already loses at 5ms RTT and everything above. - // - // Losing it is worse than never probing, because vaxis splits detect from - // enable and only detect respects the deadline. queryTerminal sets - // queries_done the moment the futex times out, and the two replies vaxis - // gates on that flag — explicit width and scaled text, both answered as a - // cursor-position report — stop being recognised as probe replies and are - // handed to US as shift-F3/alt-F3 keypresses. The replies it does NOT - // gate (mode 2027, kitty keyboard/graphics, sgr-pixels) keep landing and - // keep mutating vx.caps from the reader thread, long after - // enableDetectedFeatures ran and declined to switch those modes on. So - // over ssh the terminal sat in its default modes while caps claimed - // otherwise — kitty keyboard was never actually pushed, ever. Raising the - // timeout only moves the link speed at which that happens. - // - // Resolve it on the loop instead. DA1 is last in the probe string and - // terminals answer in order, so when vaxis's reader flips queries_done - // every earlier reply is already applied — no window left to miss at any - // latency, and the enable lands before the next frame (see caps_pending - // in pollFrame). A terminal that never answers keeps the defaults, which - // is what the 2ms timeout produced anyway, and with no caps - // enableDetectedFeatures writes no bytes — the snapshot goldens, where - // nothing ever answers, do not move. Startup gets 2ms faster, not slower. - // - // ponytail: nothing wakes the loop for DA1 alone. In practice the reply - // burst carries the mode-2048 size report too, which posts a winsize and - // turns the loop; a terminal idle from boot that lands DA1 between two - // parks keeps the defaults until the user's first keystroke (decoded - // legacy, which vaxis handles). Ceiling accepted because nothing in the - // render path reads the missing caps: pardes writes one codepoint per - // cell plus an explicit blank spacer under a wide glyph, and vaxis's - // Cell.width defaults to 1, so gwidth — the only consumer of - // caps.unicode — is never called. If that ever changes, wake the loop on - // vx.query_futex from a one-shot thread instead. + if (fs) |f| try f.wakeThread(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); - // now threads are fine: start a reader task per pty sh.threads_ok = true; for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.gens[id], loop }); }; - // ...and the one file watcher. Started here rather than lazily on the - // first watched pane because the fd already exists and an unwatched - // inotify instance just parks in read(2) — one thread for the process, - // however many panes come and go. if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; - // The core owns the loop ORDER (see Pardes.pump); the outer `while` stays - // here rather than being `core.run` for one reason: Restore swaps the - // whole core, and a core cannot replace itself from inside its own frame. frames: while (!core.quit) { try core.pump(host); - // Restore builtin: swap in a core rebuilt from the dump; the live - // shells die with their masters (readers canceled, gens bumped so - // their late eofs never touch the replay panes) if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; + const bytes = filesystem.readFile(gpa, rp) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; defer gpa.free(bytes); - var o = core.opts; - o.cols = core.screen_w; - o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(allocs.pardes, o, bytes) catch break :blk; - for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| { + const nc = core.restore(bytes) catch |err| { + core.reportError(core.active, "Restore", err); + break :blk; + }; + if (sh.lsp_task) |*task| { + task.future.cancel(io) catch {}; + sh.lsp_task = null; + } + sh.pipe_tasks.cancelAll(io); + for (&sh.gens) |*generation| generation.* +%= 1; + for (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; - sh.gens[pid] +%= 1; }; - // the replay core's pane ids mean new things, and the dying core's - // `watch off` effects go into a queue nobody drains — drop the lot - // here. The new core emits its own `on`s as it builds its panes. for (0..pardes.MAX_PANES) |wid| file_watch.watchPane( sh.inotify_fd, &sh.watches, @@ -838,33 +760,14 @@ fn localSession( _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); clearNativeImages(kitty_handles, vx, tty); nc.native_images = vx.caps.kitty_graphics; + if (fs) |f| f.reset(core); core.deinit(); core = nc; sh.core = nc; if (comptime pardes.pdf_enabled) { - // A restored core did not receive the terminal's earlier - // winsize event. Reapply both the grid and physical cells - // before its first PDF frame so pointer/pan geometry stays - // identical to placement. updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } - // `Attach [name]`: hand this terminal to a detached session and stop - // being a session at all. CONNECTING IS NOT BEING ATTACHED, which is - // why this asks `detached_client.attempt` for a GREETED client and not - // for a socket: `Client.open` writes a hello and returns, and every way - // a session says no — `refuse .version` for a session built from other - // bytes, `.full`, `.quitting`, or a plain `quit` from one that ended in - // the same round — arrives after a successful `connect(2)`. A swap that - // trusted the connect would already have SIGKILLed every pane shell, - // unmounted the filesystem and freed every undo history by the time it - // decoded the refusal. - // - // So `attached` is set only with the welcome in hand, and until it is, - // NOTHING here has been touched: a failed `Attach` costs one message - // row and leaves every pane, every shell and every undo history where - // it was. The teardown that follows is this function's own defers, - // reached by leaving its scope. if (core.takeAttach()) |req| { var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h); switch (attempt) { @@ -879,27 +782,13 @@ fn localSession( } } } - // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below. - // `loop.stop()` joins a reader parked in `read(2)` on the tty, so it does - // not return until the next keystroke — and a session that has decided to - // exit must not spend that wait holding a mount nobody is serving. A - // client blocked on `/event` when the last pane is deleted through - // `ctl` then gets ENOTCONN at once instead of hanging until somebody - // touches the keyboard. A handover skips this and lets the deferred - // unmount do it, because it does not stop the loop and so never waits. if (fs) |f| { - f.deinit(); + f.deinit(gpa); fs = null; sh.fs = null; } } -/// Free every kitty placement this session put on the terminal and forget them. -/// THREE callers and one reason: the pixels live in the TERMINAL, not in the -/// core, so a core that is replaced (`Restore`), handed away (`Attach`) or -/// simply gone (the exit) leaves placements the next frames know nothing about -/// and would paint text around. The exit's own caller follows it with `deinit`; -/// the two mid-session ones keep the map's capacity for the frames after. fn clearNativeImages( kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), vx: *vaxis.Vaxis, @@ -910,119 +799,79 @@ fn clearNativeImages( kitty_handles.clearRetainingCapacity(); } -/// Empty the event queue an attached loop leaves behind, AFTER `loop.stop()` -/// has joined vaxis's reader. Two of its events own gpa bytes — a decoded paste -/// (a bracketed burst, or an OSC 52 reply to the session's `read_clipboard`) -/// and a nested-instance command line — and the thread that posts the second -/// cannot be joined at all, so the drain is not optional on either path out. fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void { while (loop.tryEvent() catch null) |ev| switch (ev) { .paste => |b| gpa.free(@constCast(b)), - .command => |line| gpa.free(line), else => {}, }; } -/// Everything the terminal shell owns and the core cannot: the ptys, the -/// inotify table, the worker futures, and the one thread allowed to touch -/// `tty.writer()`. This struct IS the `Host.ctx`. const Shell = struct { io: std.Io, gpa: std.mem.Allocator, lsp_gpa: std.mem.Allocator, - /// live core; Restore replaces it (see run) core: *pardes.Pardes, - prompt_rcs: *const shell_bin.PromptRcs, + prompt_rcs: *const host_io.Shell.PromptFiles, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), frame: *std.heap.ArenaAllocator, - /// Where a bracketed paste is assembled. It has to outlive one drain pass: - /// the burst arrives over as many passes as the terminal takes to write - /// it, and the markers are the only thing that says where it ends. paste_buf: *std.Io.Writer.Allocating, inotify_fd: c_int, - /// acme's control filesystem for this session, or null when `--fs` was not - /// given (or its mount failed). Owned by `run`, which mounts it before the - /// first fork and tears it down on every path out. - fs: ?*fuse.Fs = null, + fs: ?*ninep_io.Listener = null, ptys: [pardes.MAX_PANES]?Pty = @splat(null), - /// per-slot spawn generation: a reused pane id ignores the old shell's - /// late pty_eof (which would otherwise close the NEW pty on that slot) gens: [pardes.MAX_PANES]u32 = @splat(0), watches: file_watch.Table = @splat(null), watch_task: ?std.Io.Future(anyerror!void) = null, - /// the single in-flight language query (see the lsp method) - lsp_task: ?std.Io.Future(anyerror!void) = null, - /// Selection filters may overlap: a second submit supersedes the first in - /// core without synchronously canceling a possibly slow shell command. + lsp_task: ?host_io.Lsp.Task = null, pipe_tasks: PipeTasks = .{}, - /// false during the pre-loop drain: no worker exists to answer to yet threads_ok: bool = false, caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, - /// the tracks the frame in flight was composed from - tracks: []const pardes.panel_animation.Track = &.{}, + tracks: []const pardes.layout.Track = &.{}, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } - fn host(s: *Shell) host_api.Host { + fn host(s: *Shell) host_io.Host { return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; } - /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on - /// and the keys it reads, and nothing else. Every other method stays null, - /// so the core answers it itself — the embedded source filesystem, the - /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is - /// opened or written, and no clipboard leaves the process. The option is - /// comptime, so the other vtable is not even built into that binary. - const isolated_vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, + const isolated_vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .post_present = postPresent, }; - const vtable: host_api.Host.VTable = .{ - .pull_wait_input = waitInput, - .push_present = present, - .push_post_present = postPresent, - .push_poll_frame = pollFrame, - .push_spawn = spawn, - .push_pty_write = ptyWrite, - .push_pty_resize = ptyResize, - .push_pty_signal = ptySignal, - .pull_tty_taken = ttyTaken, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_watch_file = watchFile, - .push_watch_theme = watchTheme, - .push_dump_themes = dumpThemes, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, - .pull_lsp = lsp, - .pull_pipe = pipe, - .push_fs_reply = fsReply, + const vtable: host_io.Host.VTable = .{ + .wait_input = waitInput, + .present = present, + .post_present = postPresent, + .poll_frame = pollFrame, + .spawn = spawn, + .pty_write = ptyWrite, + .pty_resize = ptyResize, + .pty_signal = ptySignal, + .tty_taken = ttyTaken, + .write_file = writeFile, + .write_dump = writeDump, + .watch_file = watchFile, + .watch_theme = watchTheme, + .dump_themes = dumpThemes, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, + .lsp = lsp, + .pipe = pipe, }; - // ---- input ------------------------------------------------------------ - - /// Block for one event, then apply the whole pending batch. Everything - /// goes through `core.update` rather than `postEvent`: a pty chunk borrows - /// its bytes for the call, and mixing queued with immediate delivery would - /// reorder a keystroke against the output it caused. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; if (timeout_ms == 0) { - // A failed read is a DEAD event source — the reader is gone and no - // event can ever arrive again, so nothing could set `quit` and the - // outer `while (!core.quit)` would spin at full speed. End the - // session, exactly as the pre-vtable `try loop.nextEvent()` did. const first = s.loop.nextEvent() catch { s.core.quit = true; return s.reloadWatched(); @@ -1030,20 +879,10 @@ const Shell = struct { batch = 1; if (s.apply(first)) return s.reloadWatched(); } else { - // Animating: the frame clock IS the wait, and the `.tick` that - // spends it is ours to post — `pump` cannot, because only this - // host knows when a real frame interval has passed. Anything that - // queues during the short sleep is drained below, in this pass. std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; _ = s.apply(.tick); batch = 1; } - // Apply every queued INPUT event, then render ONCE — the gui shell - // drains SDL's queue the same way. Without this a wheel flick is fifty - // full render+repaint (and re-highlight) cycles instead of one. A - // paste in flight keeps draining WITHOUT rendering: a hundred thousand - // pasted characters are one edit. That cannot spin — the drain still - // ends the moment the queue runs dry. while (s.in_paste or batch < 64) { const ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; @@ -1052,10 +891,6 @@ const Shell = struct { s.reloadWatched(); } - /// Apply one vaxis event. Returns true when the batch must end here: the - /// session is over, a pty chunk wants its own frame so progress paints as - /// it arrives, or a native PDF page crossing needs geometry this render - /// has not produced yet. fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { const core = s.core; const tz_event = tracy.zone(@src(), "event"); @@ -1077,7 +912,8 @@ const Shell = struct { core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); }, .pty_read => |pr| { - core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + if (s.gens[pr.id] == pr.gen) + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); s.gpa.free(pr.bytes); return true; }, @@ -1115,40 +951,20 @@ const Shell = struct { }, .paste_end => { s.in_paste = false; - // ONE event for the whole paste — the core borrows the - // bytes for the call, exactly like the OSC 52 arm above. const pasted = s.paste_buf.written(); if (pasted.len > 0) core.update(.{ .paste = pasted }); s.paste_buf.clearRetainingCapacity(); }, - .command => |line| { - core.update(.{ .command = line }); - s.gpa.free(line); - }, - // Coalesced on purpose: a burst of writes (a formatter, a build, a - // `git checkout`) collapses into ONE pass below, so it cannot - // queue a reload — or an undo entry — per write. .files_changed => s.check_files = true, - // A wake and nothing more. The requests behind it are drained in - // pollFrame, where the file-watch reload also happens: both want to - // run once per frame with the whole batch already in, not once per - // event. So this arm has nothing to do — which is the point, since - // its only job was ending the blocking wait above. .fs_ready => {}, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); - s.lsp_gpa.free(d.rows); - // the worker is finished; join it so its future does not - // leak (same contract as pty_eof above) - if (s.lsp_task) |*t| { - t.await(s.io) catch {}; + if (d.rows) |rows| s.lsp_gpa.free(rows); + if (s.lsp_task) |*t| if (t.id == d.id) { + t.future.await(s.io) catch {}; s.lsp_task = null; - } + }; }, - // Server state on the transient message row — the same row, the - // same `message.stamp` clock, and the same shell-side ownership a - // completed save uses. The ACTIVE pane, because the state of a - // server is session news, not a fact about the pane that asked. .lsp_status => |text| { var mbuf: [256]u8 = undefined; core.setStatus(core.active, message.stamp(&mbuf, "lsp", text)); @@ -1176,58 +992,29 @@ const Shell = struct { s.loop.postEvent(.files_changed) catch {}; } - // ---- the frame --------------------------------------------------------- - fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); - // acme's filesystem, answered here for the same reason the file-watch - // reload is (see reloadWatched): one batch per frame, not one frame per - // request. First in the pass, so an edit a script just made through - // `body` is in the surface this frame composes rather than the next. - if (s.fs) |f| if (fs_service.drain(f.transport(), s.core).pending) { - // The batch hit its cap with requests still pending, and no ack has - // gone to the poll thread — so nothing else will wake us. Re-arm - // the loop ourselves. tryPostEvent, not postEvent: this runs on the - // only thread that drains the queue, so blocking on a full one - // would deadlock, and a full queue already holds a wake. + if (s.fs) |f| if (f.tick(s.core).pending) { _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; - // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the - // pane's tty still belongs to the prompt we forked is NOT polled here — - // it is a walk through /proc and nothing draws it, so the core pulls it - // through tty_taken instead, at the Exec that cares. for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(pt.pid, &lbuf)) |cwd| s.core.setCwd(id, cwd); }; - // The handshake landed (vaxis's reader flips queries_done on DA1, the - // last probe answered): put the terminal into the modes the caps now - // claim, before anything is drawn under them. Polled here rather than - // done where the replies arrive because that is the reader thread, and - // this is the only thread allowed to touch the tty writer. The repaint - // matters as much as the enable: earlier frames were drawn under the - // pre-handshake caps, and vaxis's shadow grid has to be re-established - // under the new ones or it keeps skipping cells it thinks are current. if (s.caps_pending and s.vx.queries_done.load(.unordered)) { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; - // The core was constructed before the asynchronous handshake. - // Advertise native pixels only now, after every reply preceding - // DA1 has updated the capability set. s.core.native_images = s.vx.caps.kitty_graphics; s.vx.queueRefresh(); } } - /// The canonical surface -> vaxis, cell for cell, with no interpretation. fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); _ = s.frame.reset(.retain_capacity); - // compose only READS the canonical surface; the mutable pointer is so - // it can hand it straight back when no panel is mid-transition. const surface = panel_compositor.compose( s.frame.allocator(), @constCast(canonical), @@ -1238,16 +1025,8 @@ const Shell = struct { const win = vx.window(); paintCells(win, surface.cells, surface.cols, surface.rows); tz_cells.end(); - // Pixel attachments (kitty graphics): transmit once per pixel - // generation, then re-place every frame (placements aren't - // persistent). The map is keyed by pane lifetime + PDF page + pixel - // revision, so any number of short visible pages can coexist without - // aliasing a fixed terminal cache slot. for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; - // Keep the placement in Surface so the terminal-side cache stays - // live, but do not pin native pixels over a panel whose cells are - // currently moving through the TTY grid. if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; @@ -1288,9 +1067,6 @@ const Shell = struct { } } } - // Toggling PETSCII or closing a pane removes its attachment from the - // Surface. Release the terminal-side image then, not merely when that - // numeric pane slot happens to be reused. while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; @@ -1317,33 +1093,18 @@ const Shell = struct { tracy.frameMark(); } - // ---- pseudo-terminals --------------------------------------------------- - fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); - // the core reuses pane ids and there is no close effect: a deleted - // pane's shell lives in its slot until a respawn lands here — reap - // it (cancel joins the reader; its late eof is ignored by gen) if (s.ptys[pane]) |*old| { old.reader.cancel(s.io) catch {}; _ = libc.close(old.file.handle); s.ptys[pane] = null; } s.gens[pane] +%= 1; - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0 and cwd.len < cwd_buf.len) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); + const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd, s.core.screen_h, s.core.screen_w, s.fs) catch |err| return s.core.reportError(pane, "shell", err); s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; - // report the pane's starting directory back to the core (tags). The - // slot needs no occupancy reset: nothing is remembered, and the next - // Exec asks about the shell that is there now. - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); + var lbuf: [pardes.memory.limits.host_path_cap + 1]u8 = undefined; + if (host_io.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) { if (s.ptys[pane]) |*pt| { pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader; @@ -1364,47 +1125,27 @@ const Shell = struct { } } - /// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, - /// which is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); - if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); + if (s.ptys[pane]) |pt| host_io.signalTty(pt.pid, pt.file.handle, sig); } - /// Is a pane's tty still the prompt we forked? Lazy by construction — it - /// runs only where the core is about to type a command line, so the /proc - /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a - /// document, a slot whose shell already died) is not a terminal a program - /// can be holding. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; - return look.ttyTaken(pt.pid, pt.file.handle); + return host_io.ttyTaken(pt.pid, pt.file.handle); } - // ---- the filesystem ----------------------------------------------------- - fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); - // SAY WHY, and tell the core it did not happen. A save that cannot be - // done is the one failure this program must never swallow: `saveFailed` - // puts the reason on the pane's message row and leaves the pane dirty, - // so the ` *` stays and `Del` cannot quietly take the edits. - host_io.writeFileBytes(path, bytes) catch |err| + filesystem.write(s.core, path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); - // our own write is about to come back as a watch event: restamp from - // the bytes we just put there so it reads as "no change". Only when - // this IS the pane's watched file — a `Save ` must not - // silence a real change to the file the pane has open. if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; - // ...and say so on the pane's message row. AFTER the write, not beside - // it: every early return above is a save that did not happen and must - // not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } @@ -1413,13 +1154,13 @@ const Shell = struct { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; - host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); + filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } - fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { + fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool, mode: pardes.WatchMode) void { const s = of(ctx); - if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on)) + if (file_watch.applyEffect(s.core, s.io, s.inotify_fd, &s.watches, pane, on, mode)) s.loop.postEvent(.files_changed) catch {}; } @@ -1429,21 +1170,10 @@ const Shell = struct { s.loop.postEvent(.files_changed) catch {}; } - /// The core's answer to one filesystem request, handed straight back to the - /// transport that is holding it. `bytes` was resolved by `pardes.fsPayload` - /// inside `perform` and is borrowed only for this call — a body read is a - /// window onto the pane's live text, so there is nothing to copy and - /// nothing to free. `.again` needs no special case here: `Fs.reply` reads - /// the status and re-parks the request itself. - fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { - const s = of(ctx); - if (s.fs) |f| f.reply(reply, bytes); - } - fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); const config_dir = s.core.opts.config_dir orelse return; - const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { + const out_dir = pardes.config.User.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; @@ -1452,16 +1182,6 @@ const Shell = struct { s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } - // ---- the desktop -------------------------------------------------------- - // - // The three effects a detached session still puts on the wire - // (`wire.ServerTag` 0x10..), because each of them needs the display a - // human is actually looking at rather than the machine the core runs on. - // These are the `Host.ctx` shims and nothing else: the terminal work is - // `copyToClipboard`/`requestClipboard` below this struct, so an attached - // frontend serving `set_clipboard`/`read_clipboard` writes the same escape - // sequences from the same lines. - fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); copyToClipboard(s.vx, s.tty, s.gpa, text); @@ -1476,96 +1196,171 @@ const Shell = struct { look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly } - // ---- work that must leave the loop -------------------------------------- - - fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { + fn lsp(ctx: ?*anyopaque, req: host_io.Lsp.Request) void { const s = of(ctx); - if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet - const job = lsp_host.snapshot(s.lsp_gpa, s.core, req) orelse return; - // ponytail: ONE query in flight, so one future slot. Replacing it - // cancels-then-joins the previous worker, which for a backend that - // ignores cancellation means waiting out a query the user already - // abandoned. Queries are milliseconds; make this a real pool the day a - // backend takes long enough to notice. + if (!s.threads_ok) { + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", error.WorkersUnavailable); + } + const job = host_io.Lsp.snapshot(s.lsp_gpa, s.core, req) catch |err| { + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", err); + }; if (s.lsp_task) |*old| { - old.cancel(s.io) catch {}; + old.future.cancel(s.io) catch {}; s.lsp_task = null; } - s.lsp_task = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch { + const future = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch |err| { job.free(s.lsp_gpa); - return; + s.core.update(.{ .lsp_resp = .{ .id = req.id, .rows = null } }); + return s.core.reportError(req.pane, "lsp", err); }; + s.lsp_task = .{ .id = req.id, .future = future }; } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = of(ctx); - if (!s.threads_ok) return; + if (!s.threads_ok) { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", error.WorkersUnavailable); + } if (s.pipe_tasks.full()) { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = s.core.pipeRequest(id) orelse return; - const job = selection_pipe.Job.copy(s.gpa, view) catch return; - const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch { + const job = selection_pipe.Job.copy(s.gpa, view) catch |err| { + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); + }; + const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch |err| { job.deinit(s.gpa); - return; + s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); + return s.core.reportError(s.core.active, "pipe", err); }; - // `full()` was checked above, so this cannot fail; assert rather than - // discard, because a silently dropped task is a future nobody joins. std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); } }; -/// Mirror a yank register out via OSC 52. Free functions over the terminal -/// they write to rather than `Shell` methods, because the clipboard is the one -/// piece of host work that survived the move into the daemon and BOTH loops in -/// this file perform it: `Shell` for its own core's `Effect.set_clipboard`, and -/// `Attach` for a detached session's `wire.ServerMsg.set_clipboard`. One -/// escape sequence written in two places is one that drifts. +test "TTY queued results reject old PTY generations and LSP request IDs" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer core.deinit(); + var shell: Shell = .{ + .io = std.testing.io, + .gpa = gpa, + .lsp_gpa = gpa, + .core = core, + .prompt_rcs = undefined, + .loop = undefined, + .vx = undefined, + .tty = undefined, + .kitty = undefined, + .frame = undefined, + .paste_buf = undefined, + .inotify_fd = -1, + }; + 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); + 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); + + core.lspRequest(0, .status, ""); + const old_id = core.lsp_wait.?.id; + core.lspRequest(0, .status, ""); + const current_id = core.lsp_wait.?.id; + shell.lsp_task = .{ .id = current_id, .future = .{ .any_future = null, .result = {} } }; + _ = shell.apply(.{ .lsp_done = .{ .id = old_id, .rows = try gpa.dupe(u8, "stale result\n") } }); + try std.testing.expect(shell.lsp_task != null); + try std.testing.expectEqual(current_id, shell.lsp_task.?.id); + try std.testing.expectEqual(current_id, core.lsp_wait.?.id); + _ = shell.apply(.{ .lsp_done = .{ .id = current_id, .rows = try gpa.dupe(u8, "") } }); + try std.testing.expect(shell.lsp_task == null); + try std.testing.expect(core.lsp_wait == null); +} + +test "TTY worker setup failures finish matching LSP and pipe requests" { + const gpa = std.testing.allocator; + var failing_vtable = std.testing.io.vtable.*; + failing_vtable.concurrent = std.Io.failingConcurrent; + const failing_io: std.Io = .{ .userdata = std.testing.io.userdata, .vtable = &failing_vtable }; + for (0..2) |failure| { + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + const pane = try core.setTestFile("abc"); + var failing = std.testing.FailingAllocator.init(gpa, .{ + .fail_index = if (failure == 0) 0 else std.math.maxInt(usize), + }); + var shell: Shell = .{ + .io = failing_io, + .gpa = failing.allocator(), + .lsp_gpa = failing.allocator(), + .core = core, + .prompt_rcs = undefined, + .loop = undefined, + .vx = undefined, + .tty = undefined, + .kitty = undefined, + .frame = undefined, + .paste_buf = undefined, + .inotify_fd = -1, + .threads_ok = true, + }; + core.lspRequest(core.active, .status, ""); + const req: host_io.Lsp.Request = .{ + .id = core.lsp_wait.?.id, + .kind = .status, + .pane = @intCast(core.active), + .offset = 0, + .arg = "", + }; + Shell.lsp(&shell, req); + try std.testing.expect(core.lsp_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + core.update(.{ .key = .{ .cp = '|' } }); + core.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + core.update(.{ .key = .{ .cp = pardes.Key.enter } }); + const pipe_id = core.pipe_wait.?.id; + Shell.pipe(&shell, pipe_id); + try std.testing.expect(core.pipe_wait == null); + try std.testing.expectEqualStrings("abc", pane.file.?.content); + try std.testing.expect(shell.lsp_task == null); + try std.testing.expectEqual(@as(usize, 0), shell.pipe_tasks.len); + } +} + fn copyToClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty, gpa: std.mem.Allocator, text: []const u8) void { if (text.len == 0) return; vx.copyToSystemClipboard(tty.writer(), text, gpa) catch {}; } -/// ...and the other direction, OSC 52 read. The answer arrives on vaxis's -/// reader thread as an ordinary `.paste` event and reaches whoever asked — -/// the local core through `Shell`, the session through `ClientTag.event` — -/// by the same path an outer bracketed paste takes; this request is the only -/// wiring it needs. "The answer arrives" is the optimistic reading: a -/// clipboard READ is an exfiltration primitive and terminals treat it as one -/// (ghostty prompts by default, xterm ships it off, a multiplexer or ssh link -/// may eat it), and a refusal looks exactly like silence. So the core's -/// pending request is dropped by the next keystroke rather than pasting -/// minutes late, and `SPC p` in a locked-down terminal honestly does nothing. fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } -/// Answer a language query off the event loop and post the rows back. The -/// snapshot and the query body are `lsp_host`'s; the only part that is this -/// shell's is the vaxis event the rows travel home on. -fn lspWorker(allocator: std.mem.Allocator, job: *lsp_host.Job, loop: *Loop) anyerror!void { +fn lspWorker(allocator: std.mem.Allocator, job: *host_io.Lsp.Job, loop: *Loop) anyerror!void { var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; - lsp_host.work(allocator, job, &sink, LspRowSink.take); + host_io.Lsp.work(allocator, job, &sink, LspRowSink.take); } const LspRowSink = struct { allocator: std.mem.Allocator, loop: *Loop, - fn take(ctx: ?*anyopaque, id: u32, rows: []u8) void { + fn take(ctx: ?*anyopaque, id: u32, rows: ?[]u8) void { const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); - s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch s.allocator.free(rows); + s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch { + if (rows) |owned| s.allocator.free(owned); + }; } }; -/// The registered `lsp.setStatusSink` target, called from the protocol -/// client's READER threads. Only thread-safe, NON-BLOCKING things happen -/// here: a dupe with the concurrent lsp allocator and a TRY-post onto the -/// loop's queue. Never the blocking post — the sink lock is held around this -/// call, and a full queue plus a teardown spinning on that lock would be a -/// deadlock; server state is periodic news, so a dropped line is repriced -/// by the next one. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const s: *Shell = @ptrCast(@alignCast(ctx orelse return)); const copy = s.lsp_gpa.dupe(u8, text) catch return; @@ -1584,22 +1379,7 @@ fn pipeWorker( loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); } -/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the -/// events: the loop re-reads every watched pane anyway, so the only thing an -/// event carries that we need is THAT something happened, and parsing would -/// mean sharing the watch table with the thread that mutates it. Same shape as -/// readPty — block off the loop, hand the loop an event, keep the core a state -/// machine that never blocks. Going through std.Io.File rather than a raw -/// read(2) is what lets the teardown `cancel` interrupt it. -/// -/// ponytail: churn in a watched directory that never touches the watched file -/// still costs a wake and a re-read per event. The ceiling is one directory -/// per open file pane; filter by basename here if it ever shows up in a -/// profile. fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { - // A kqueue cannot be read, so there is no std.Io.File to wrap and no - // `cancel` to interrupt: this arm parks in kevent(2) and the teardown's - // `file_watch.stop` is what releases it. See file_watch.wait. if (comptime builtin.os.tag != .linux) { while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; return; @@ -1616,32 +1396,6 @@ fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { } } -/// Block on the nested-instance socket and hand the loop each command line a -/// pardes started inside this one sends. Same shape as winchWatch: a plain -/// detached thread around a call that never returns, posting into the vaxis -/// loop from ordinary thread context. -/// -/// Nothing here is woken by teardown — close(2) does NOT release a thread -/// parked in accept4 on linux — so this dies with the process, exactly as -/// winchWatch dies inside sigwait. The window that leaves is one connection -/// accepted between the last drain and process exit posting into a queue whose -/// owner has returned; same shape and same bound as every other detached -/// worker here, and a self-pipe to close it would be more machinery than the -/// window is worth. -fn lookServer(gpa: std.mem.Allocator, fd: c_int, loop: *Loop) void { - var buf: [nested.max_line]u8 = undefined; - while (nested.acceptLine(fd, &buf)) |line| { - const owned = gpa.dupe(u8, line) catch continue; - loop.postEvent(.{ .command = owned }) catch { - gpa.free(owned); - break; - }; - } -} - -/// Consume SIGWINCH synchronously (it is blocked in every thread) and post -/// the new size as a winsize event from normal thread context — the one place -/// vaxis's Io-backed queue is safe to touch on a resize. fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); @@ -1654,11 +1408,6 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -/// The /dev/fuse poller's wake, and deliberately nothing else — the thread that -/// calls this has no business in the core, so all it does is end the blocking -/// `nextEvent`. tryPostEvent rather than postEvent for the same reason readPty's -/// final post uses it: this can fire after the loop has already been left, and a -/// blocking push into a full queue nobody is draining would never return. fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; @@ -1673,18 +1422,14 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = try gpa.dupe(u8, buf[0..n]); - loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch { + loop.postEvent(.{ .pty_read = .{ .id = id, .gen = gen, .bytes = bytes } }) catch { gpa.free(bytes); break; }; } - // non-blocking: a teardown cancel only unblocks one wait, so a blocking - // post into a full queue here could hang the exit _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; } -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; @@ -1696,8 +1441,6 @@ fn effCp(key: vaxis.Key) u21 { return key.shifted_codepoint orelse key.codepoint; } -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, @@ -1713,37 +1456,17 @@ fn mapKey(cp: u21) u21 { }; } -/// 4 MiB ceiling, past which the tail is dropped rather than grown into. A -/// paste that large is a mis-click on a file, not an edit, and the core would -/// have to hold the whole of it as one undo entry. File scope rather than a -/// `Shell` decl because the `--attach` frontend accumulates the same bursts -/// against the same ceiling, and wire.zig cites this name as THE cap. const max_paste_bytes: usize = 4 << 20; -/// One key press between the bracketed-paste markers, as the bytes it means. -/// A key in there is DATA, never a command, and the two callers — the -/// in-process host and the `--attach` frontend — must agree exactly, because -/// what they produce is compared against what a terminal's own paste would -/// have delivered. fn pasteBytes(key: vaxis.Key) []const u8 { const text = key.text orelse ""; if (text.len > 0) return text; const cp = mapKey(effCp(key)); if (cp == pardes.Key.tab) return "\t"; - // vaxis gives control bytes no text at all: a line break inside a paste - // reaches the ground parser as a bare CR (-> Key.enter) or, from a - // terminal that does not translate them, a bare LF — which that parser - // reports as ctrl+j. Nothing in here is a real keypress, so both of them - // are just a newline. if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; - // arrows, F-keys, a stray escape: noise a paste has no business carrying, - // dropped rather than smuggled in. return ""; } -/// ...and one ordinary key press as the core's event. Shared for the reason -/// above: a keystroke must mean the same thing whether the core is in this -/// process or on the other end of a socket. fn keyEvent(key: vaxis.Key) pardes.Event { return .{ .key = .{ .cp = mapKey(effCp(key)), @@ -1754,9 +1477,6 @@ fn keyEvent(key: vaxis.Key) pardes.Event { } }; } -/// ...and one mouse report. Null for a button this vocabulary has no name for -/// (vaxis reports more of them than the core has), which is a report to drop -/// rather than a press to invent. fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { const button: pardes.Mouse.Button = switch (m.button) { .left => .left, @@ -1783,14 +1503,6 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { } }; } -/// THE cell walk: canonical cells -> vaxis, cell for cell, with no -/// interpretation. One walk and two callers, because a `frame` off the -/// detached wire IS a `Surface`'s cells — wire.zig carries the grid and -/// deliberately does not carry the two halves `Shell.present` adds around this -/// (the kitty attachments, which have no encoding, and the panel transition, -/// which the session composes before it sends). A second walk here would be -/// two renderers for one canonical interface, and the interface is the thing -/// this editor is. fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; @@ -1809,7 +1521,6 @@ fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u1 fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); - // insert = beam, everything else = the terminal's default shape win.setCursorShape(if (bar) .beam else .default); } @@ -1843,60 +1554,19 @@ fn vaxisColor(c: pardes.Color) vaxis.Color { }; } -// --------------------------------------------------------------------------- -// Attached: a terminal, a socket, and no core -// -// Reached two ways — `--attach[=]` on the command line, and the `Attach` -// builtin handing a running local session's terminal to a detached one — and -// identical past the connect. NOTHING below this line forks a shell, writes a -// file or watches a path: the daemon owns every machine-local effect now -// (src/detached/server.zig), and the three that are still on the wire -// (`wire.ServerTag` 0x10..) are there because the clipboard and the browser -// are the human's, not the machine's. -// --------------------------------------------------------------------------- - -/// Why an `--attach` frontend stopped, and where its exit status comes from. -/// A VALUE rather than a message printed where it is discovered: at that point -/// the alt screen is still up and everything written to it is erased by the -/// restore a moment later. `run` registers `report` BEFORE it opens the -/// terminal, so LIFO runs it last — on a cooked main screen, which is the one -/// screen a person would go looking at. const AttachEnd = union(enum) { - /// not an `--attach` run at all, or the session said `quit`: exit 0 none, - /// `--attach=` and nothing is listening under it no_session: []const u8, - /// bare `--attach` with no session to mean... nothing_detached, - /// ...or more than one, which is a choice and not ours to make ambiguous: usize, - /// the session hung up on the connect and said why refused: wire.Refusal, - /// the link died, or the stream stopped making sense lost: anyerror, - /// the connect landed and the session hung up before its reason arrived: a - /// refusal whose six bytes raced the close (server.zig `refuseFd`) rejected, - /// ...and the other silence: it accepted, kept the slot, and never greeted - /// us at all inside client.zig's `attempt` deadline silent, - /// `Detach` in an attached frontend: THIS frontend leaves and the session - /// does not, which is the whole difference between it and `.none`. The name - /// is what to come back to, and empty when this frontend never knew it (an - /// `Attach` builtin's bare form: the core that held the word is gone by the - /// time the attached loop runs). detached: []const u8, - /// The nonzero exit lives HERE for the same reason the message does: every - /// teardown this process owes is a defer registered after this one, so by - /// the time this runs they have all run and there is nothing left to skip. fn report(e: AttachEnd, io: std.Io) void { var buf: [512]u8 = undefined; - // Set by the one ending that is not a failure. `Detach` is a word the - // user typed, so leaving is success: the line goes to STDOUT and the - // exit status is untouched, because there is still a session there to - // come back to. tmux's `[detached]` line is the same sentence for the - // same reason. var ok = false; const text: []const u8 = switch (e) { .none => return, @@ -1934,17 +1604,8 @@ const AttachEnd = union(enum) { if (!ok) std.process.exit(1); } - /// The same reason on ONE pane message row, for the `Attach` builtin. It - /// exists because that path does not exit: `report` writes to a cooked main - /// screen on the way out of the process and can spend a clause on advice, - /// while this shares a row with a filename in a session that goes on - /// running. Same vocabulary, no `pardes:` prefix and no newline. fn row(e: AttachEnd, buf: []u8) []const u8 { return switch (e) { - // `report` reads `.none` as "exit 0, say nothing", and a message - // row only ever shows a failure — but a session that says `quit` - // before it greets is the one way this arm could be reached, and - // that is what it says. .none => "attach: that session ended", .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch "attach: no session under that name", @@ -1959,39 +1620,11 @@ const AttachEnd = union(enum) { .lost => |err| std.fmt.bufPrint(buf, "attach: {t}", .{err}) catch "attach: link lost", .rejected => "attach: that session hung up on the connect", .silent => "attach: that session accepted and never greeted", - // Never asked for: `attemptEnd` is the only caller and a connect - // that has not happened yet cannot have been detached from. Worded - // rather than left to an `else`, so the arm somebody adds next - // still has to be thought about. .detached => "attach: detached from that session", }; } }; -/// `--attach[=]` and the `Attach` builtin: the frontend half of a -/// detached session. This process owns a terminal and a socket; the `Pardes` -/// is in the session process (src/detached/). The whole job is client.zig's -/// two sentences — send the input it collects, draw the frames it is sent — -/// and since the daemon took its own IO back there is nothing else in it. -/// -/// THAT DELETION IS THE POINT. A frontend used to serve `spawn`, `pty_write`, -/// `pty_resize`, `write_file`, `write_dump`, `watch_file` and `dump_themes` -/// off the wire, which put every pane's shell in whichever frontend happened -/// to fork it and stopped that pane's output the moment that frontend left — -/// a daemon whose whole promise is outliving frontends killed your shells. A -/// unix socket means the two ends share a machine, so the daemon forks and -/// writes and watches for itself (src/host_io.zig, src/file_watch.zig) and -/// `wire.ServerTag` keeps exactly three effects, 0x10..: the clipboard both -/// ways and the browser, because each of those needs the display a human is -/// actually looking at. -/// -/// It is NOT a `Shell`, and the difference is not size. `Shell` IS the -/// `Host.ctx` of a core in THIS process, and its methods reach into that core -/// on nearly every line — a pane's message row, `acknowledgeShell`, `setCwd`, -/// a watch generation taken off the pane's live text. With no core those are -/// not cheaper versions of the same work, they are absent. What the two -/// genuinely share is shared: the cell walk, the key and mouse vocabularies, -/// the paste ceiling, `copyToClipboard`, `requestClipboard`. const Attach = struct { gpa: std.mem.Allocator, client: *detached_client.Client, @@ -1999,97 +1632,41 @@ const Attach = struct { vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_buf: *std.Io.Writer.Allocating, - /// The session this frontend asked for, borrowed for the loop's lifetime - /// and only so `Detach` can name what to come back to. Empty when it is not - /// knowable here — see `AttachEnd.detached`. session: []const u8 = &.{}, caps_pending: bool = true, in_paste: bool = false, - /// A frame landed. Painted once at the end of the round rather than where - /// it arrives: several can be decoded out of one poll and only the last of - /// them is on the screen. dirty: bool = false, - /// One event onto the wire. Non-null ends this frontend. fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { a.client.send(.{ .event = ev }) catch |err| switch (err) { - // A message this protocol cannot carry, which is not a link that - // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB). - // One event still reaches it now that the watched files are the - // daemon's — an OSC 52 clipboard reply, whose size is whatever the - // terminal handed vaxis and which nothing in this file bounds - // (`max_paste_bytes` bounds the bracketed-paste assembly, not a - // decoded reply). Dropping it costs one paste; treating it as a - // hangup would cost the session. error.Overlong, error.NoSpace => return null, else => return .{ .lost = err }, }; return null; } - /// One decoded message from the session. `wire.ServerMsg` has eight arms - /// and so has this switch — no catch-all, so a protocol that grows a ninth - /// stops compiling here rather than quietly ignoring it. fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { switch (msg) { - // Already applied to the client's slot and geometry; the full frame - // the session promises a fresh attach is the next thing to arrive. .welcome => {}, .refuse => |why| return .{ .refused = why }, - // Applied too — `grid` and `cursor` are current by the time this - // returns, so all that is left is to say the screen moved. .frame => a.dirty = true, .quit => return .none, - // ...and its sibling, which is the same exit for the opposite - // reason: `quit` is the session ending under every frontend, and - // `Detach` is THIS frontend leaving one that carries on. The - // session keeps its panes, its shells and its other frontends, so - // there is nothing to report as a failure and something to come - // back to — see `AttachEnd.detached`. .detach => return .{ .detached = a.session }, - // The three that are left, served by the same lines the local - // `Shell` runs for its own core's effects: this terminal's OSC 52 - // pair and this desktop's browser. .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), - // The answer is not a reply message: it comes back as an ordinary - // `Event.paste` through the `.paste` arm of `apply`, like any other - // input, which is the same asynchronous shape `pull_read_clipboard` - // has in-process. .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), } return null; } - /// One vaxis event, translated onto the wire. Non-null ends the loop. fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { - // Nothing posts these here, and each absence has a reason. `tick` - // is `Shell.waitInput`'s animation clock, and an animation runs - // where the core is — the session sleeps on its own frame interval - // (server.zig `nap`) and the frames simply arrive. `fs_ready` - // belongs to `--fs`, which lives with the core. `lsp_done` and - // `pipe_done` answer work the core dispatches, and it dispatches it - // there; `lsp_status` narrates servers whose sink the local loop - // UNSET in its teardown before this loop started, and the queue - // was drained after that, so none is in flight. `pty_read`, - // `pty_eof` and `files_changed` are the ones that MOVED: the - // daemon forks the pane shells and holds the inotify instance - // now, so the only descriptors this process reads are its - // terminal and one socket. An in-place switch (`Attach` in a - // local session) cancels its readers and its watcher and drains - // this queue before the attached loop starts, so not even a late - // post from the session it just left arrives here. .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), .winsize => |ws| { a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; - // Repaint from the frame already in hand: vaxis has just thrown - // its shadow grid away, and the SESSION grid may not move at - // all — it is the smallest common one and another frontend may - // be the small one (client.zig GEOMETRY). a.dirty = true; a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; }, @@ -2110,39 +1687,17 @@ const Attach = struct { .paste_end => { a.in_paste = false; defer a.paste_buf.clearRetainingCapacity(); - // ONE message for the whole paste, exactly as the in-process - // host makes it one `update`. const pasted = a.paste_buf.written(); if (pasted.len > 0) return a.send(.{ .paste = pasted }); }, - // A pardes launched inside a pane shell hands its file to the - // nearest pardes ANCESTOR (nested.zig `outer`), and now that the - // daemon forks those shells that ancestor is the daemon — which is - // why `attachSession` binds no listener at all. The one line that - // still reaches this arm is a switch racing itself: a local session - // whose own child wrote to `localSession`'s listener in the moment - // before `Attach` gave the terminal away, on a thread that is - // detached and so cannot be joined ahead of the queue drain. It - // goes over the wire, which is what `ClientTag.command` is for. - .command => |line| { - defer a.gpa.free(line); - return a.send(.{ .command = line }); - }, } return null; } - /// The capability handshake, resolved on the loop exactly as - /// `Shell.pollFrame` resolves it and for its reason: the replies land on - /// vaxis's reader thread, and this is the only thread allowed to write to - /// the tty. No `native_images` here — this wire carries no attachments. fn enableCaps(a: *Attach) void { if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return; a.caps_pending = false; a.vx.enableDetectedFeatures(a.tty.writer()) catch {}; - // Earlier frames were drawn under the pre-handshake caps, and vaxis's - // shadow grid has to be re-established under the new ones or it keeps - // skipping cells it thinks are current. a.vx.queueRefresh(); a.dirty = true; } @@ -2150,72 +1705,33 @@ const Attach = struct { fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); - // The session grid can be smaller than this window; `paintCells` clears - // first, so the surplus is the terminal's own default cell rather than - // whatever was there a frame ago. 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 {}; } }; -/// One `detached_client.Attempt` that did NOT come back with a client, in this -/// file's own vocabulary. `requested` is what the user actually typed, because -/// the union has a single `no_session` where this file has two ends for it: a -/// name that resolved to nothing is a typo to correct, and no name at all is a -/// session to start. fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd { return switch (a.*) { - // Both callers take the client out of the `.greeted` arm themselves, so - // this is only ever asked about a failure; `.none` is what "nothing to - // report" is spelled as everywhere else in this union. .greeted => .none, .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached, .ambiguous => |found| .{ .ambiguous = found }, .refused => |why| .{ .refused = why }, .silent => .silent, - // A hangup with no reason decoded is what `rejected` was written for: - // server.zig's `refuseFd` writes six bytes and closes in the same pass, - // so the close can beat the reason onto the socket. client.zig's `give` - // already prefers a refusal it did decode, so an `error.Closed` that - // reaches here is that race and nothing else. .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err }, }; } -/// The attached loop: two event sources, one screen, no core. A function of its -/// own because there are two ways to become attached and only one loop — -/// `--attach` on the command line (`attachSession`, which opens the terminal -/// for it) and the `Attach` builtin (see `localSession`, whose terminal already -/// had one) — and past the connect the two are indistinguishable. Every thread -/// it needs (vaxis's reader, the SIGWINCH sigwait) is the caller's to have -/// started, which is the whole difference between the two entries. fn attachLoop(a: *Attach) AttachEnd { while (true) { const link = a.client.wait(detached_client.poll_ms); - // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in - // the same call that read the last bytes, and the last bytes are the - // session's `quit`: `fill` appends every chunk and only then sees the - // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly - // this reason, and honouring that means draining what arrived before - // deciding the link is what ended us — otherwise an ordinary `Kill` - // exits one frontend 0 (it got the quit alone) and whichever frontend - // was in the same poll round nonzero, which is what the first run of - // this loop actually did. while (true) { const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break; if (a.handle(msg)) |end| return end; } link catch |err| return .{ .lost = err }; - // The terminal, drained the way `Shell.waitInput` drains it and for its - // reason: a wheel flick is one batch rather than fifty round trips, and - // a paste in flight keeps draining without a message per character. var batch: usize = 0; while (a.in_paste or batch < 64) { - // Propagated rather than swallowed, unlike `Shell.waitInput`'s - // identical drain: there the blocking `nextEvent` above it is what - // notices a dead event source, and here there is no blocking read - // to notice with. const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; batch += 1; if (a.apply(ev)) |end| return end; @@ -2225,48 +1741,23 @@ fn attachLoop(a: *Attach) AttachEnd { } } -/// The whole `--attach` run: connect, then `attachLoop` until the session, the -/// link or the terminal ends it. The terminal is already raw, on the alt screen -/// and reporting the mouse — `run` did that, and `run`'s defers undo it, which -/// is what makes an attach leave a terminal in exactly the state an ordinary -/// exit does. -/// -/// No `Options` reaches here any more. Every field of it describes a core, and -/// the last two this frontend read went with the work that read them: the -/// config directory served a `dump_themes` the daemon now does itself, and -/// `nested` gated a listener for children this process no longer has. fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { const io = init.io; const gpa = init.gpa; - // The hello carries this window, so the size has to be real before it goes - // out: a session told 80x24 by a 200x50 terminal reflows every pane twice, - // once now and once on the first SIGWINCH. `vx.resize` here rather than - // waiting for the loop's first event for the same reason — the first frame - // may arrive before any terminal event does, and it has to have somewhere - // to be painted. const ws = tty.getWinsize() catch |err| return .{ .lost = err }; if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize }; vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err }; - // The SAME three steps the `Attach` builtin takes, through the same - // function, because the two entries drifted apart the last time they were - // written separately: `--attach` resolved a bare name and the builtin did - // not, so the documented `SPC s a` answered `NoSessionPath` at a session - // that was listening. `attempt` resolves, connects, and waits to be - // GREETED. This path could afford to meet a refusal inside the loop below - // — it has no local session to lose — but there is no second sequence to - // maintain, so it does not have its own. var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows); var client = switch (attempt) { .greeted => |c| c, else => return attemptEnd(&attempt, name), }; - // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into - // "the peer left" in the session's log. defer client.detach(); var loop: Loop = .init(io, tty, vx); + var input_cache: vaxis.GraphemeCache = .{}; var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); @@ -2277,26 +1768,13 @@ fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: .vx = vx, .tty = tty, .paste_buf = &paste_buf, - // Concrete here, unlike the builtin's path: `run` resolved a bare - // `--attach` to one name before it opened the terminal, and it outlives - // this call. So a `Detach` from a `--attach` frontend can say what to - // come back to. .session = name, }; - // The whole teardown this frontend owes, which is now one queue drain: no - // ptys, no watches, no workers, nothing forked. Registered BEFORE - // `loop.stop()` below so LIFO runs it after — vaxis's reader has to be - // joined before the queue is emptied, or a late post lands in a queue - // nobody drains again and its bytes leak. defer drainAttachedQueue(&loop, gpa); - loop.start() catch |err| return .{ .lost = err }; - defer loop.stop(); - // Detached rather than an `io.concurrent` task, for `run`'s reason: sigwait - // never returns, so a task around it would hang the teardown that joins it. + startInput(&loop, &input_cache) catch |err| return .{ .lost = err }; + defer stopInput(&loop); (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach(); - // Send the capability probes and do not wait on them; `Attach.enableCaps` - // resolves them on the loop. run() has the long version of why. vx.queryTerminalSend(tty.writer()) catch {}; return attachLoop(&a); diff --git a/src/tutor.txt b/src/tutor.txt index f712ab85..b2110dd8 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -230,11 +230,9 @@ WHAT A DAEMON CAN ALSO DO A detached session serves acme's control filesystem like any other: - pardes --detach=work --fs the tree under $XDG_RUNTIME_DIR - pardes --detach=work --fs9 the same tree, as 9P on a unix socket + pardes --detach=work 9P on the session's default unix socket - Either, both or neither. That is what makes a daemon scriptable while - nobody is looking at it — see part 6. + Every native session is scriptable over 9P — see part 6. The socket lives in $XDG_RUNTIME_DIR (else ~/.local/state/pardes), created 0700, never /tmp: it carries keystrokes into a live editor. @@ -368,7 +366,7 @@ typed abandons it, and so does any key that leads nowhere. SPC ? list every path - SPC k Kill SPC d Del + SPC d Del Kill remains available in the topbar. SPC f s Save SPC f f Find SPC f n New SPC y Y p P R the system clipboard SPC w h/j/k/l focus a neighbouring pane @@ -397,46 +395,43 @@ typed plugin API, no interpreter and no rebuild. A program that opens files IS an extension. - pardes --fs acme's control files over FUSE - pardes --fs9 the same tree as 9P on a unix socket + pardes the 9P socket opens by default A directory per pane holding `body`, `tag`, `ctl`, `addr`, `data`, `event`, `pty/` and the rest, plus `index`, `cons` and `new/` at the - top. Every pane shell is told `$PARDES_FS` and `$PARDES_PANE`, so a - script in a pane addresses its own window with no arguments: + top under /self. Every pane shell receives `$PARDES_9P` (the socket) + and `$PARDES_PANE` (its serial). Use a 9P client to read and write: - echo hello >> $PARDES_FS/$PARDES_PANE/body - cat $PARDES_FS/index - echo hi > $PARDES_FS/new/body + /self/pane//body + /self/index + /self/new/ctl A terminal pane also has `pty/`: - echo 'winsize 100 30' >> $PARDES_FS/3/pty/ctl - echo 'sig INT' >> $PARDES_FS/3/pty/ctl - echo 'make -j8' >> $PARDES_FS/3/pty/data + /self/pane/3/pty/ctl winsize, sig + /self/pane/3/pty/data terminal input/output Over 9P the same tree answers plan9port, from anywhere: 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock ls / - 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /index + 9p -a $XDG_RUNTIME_DIR/pardes-9p-work.sock read /self/index ...and pardes is a 9P CLIENT too, so one session can read another's: - 9p work.sock /1/body the `9p` word: opens it in a pane + pardes --mount=peer=work + /n/peer/self/pane/1/body Look opens the other session's body TWO THINGS WORTH KNOWING. While a program holds a pane's `event` file open, MIDDLE AND RIGHT CLICKS IN THAT PANE BELONG TO IT: pardes reports them and performs nothing, so that pane's tag can carry the program's - own words (examples/acmefs/life.py puts Step/Run/Clear there). The - keyboard is never suppressed, and the clicks come back when it exits. + own words. The keyboard is never suppressed, and the clicks come back + when it exits. And writing an event record back — `origin type q0 q1` — makes pardes perform the Look or Exec it names. That is arbitrary command execution - by design, the same door acme has always had, which is why the mount - sits under $XDG_RUNTIME_DIR at 0700 and why both flags are opt-in. + by design. The socket sits in the user's private runtime directory. - examples/README.md is the client's guide; docs/acme-fs.md compares this - implementation with acme's file by file; docs/9p.typ is the 9P note. + docs/fs.md describes the filesystem and mount paths. ================================================================= @@ -466,7 +461,7 @@ typed Attach / Detach the same, as words (SPC s a / s D) the pane shells belong to the SESSION and never stop N frontends share ONE screen at the smallest common grid - --fs and --fs9 work in a daemon too + the default 9P socket works in a daemon too KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p i a I A o O u undo U redo @@ -480,8 +475,8 @@ typed / is a case-insensitive SUBSTRING search, not a regex SPC is the leader (SPC ? lists every path) - SCRIPTING --fs acme's files over FUSE; --fs9 the same over 9P - $PARDES_FS//{body,tag,ctl,addr,data,event,pty/} + SCRIPTING 9P is served by default on $PARDES_9P + /self/pane//{body,tag,ctl,addr,data,event,pty/} a script holding `event` owns that pane's middle and right clicks; writing a record back runs it diff --git a/src/user_config.zig b/src/user_config.zig deleted file mode 100644 index 15253ab2..00000000 --- a/src/user_config.zig +++ /dev/null @@ -1,188 +0,0 @@ -//! Native startup configuration discovery and loading. -//! -//! `pardes` is a directory inside the platform's per-user configuration -//! directory; its startup command file is `pardes/init`. Discovery is -//! deliberately launcher-owned: native -//! launchers opt in by putting the bytes in `Options.startup_config`, while -//! tests and the browser keep the default `null`. - -const std = @import("std"); -const builtin = @import("builtin"); - -const max_bytes = 1024 * 1024; - -pub const init_name = "init"; -pub const builtin_themes_subdir = "themes/builtin"; - -/// Resolve the native per-user config directory with stdlib environment and path -/// APIs. XDG_CONFIG_HOME is accepted only when absolute, as required by the -/// XDG base-directory specification; an empty/relative value falls back. -pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { - if (builtin.os.tag == .windows) { - if (nonEmpty(env.get("LOCALAPPDATA"))) |base| - return try std.fs.path.join(gpa, &.{ base, "pardes" }); - if (nonEmpty(env.get("USERPROFILE"))) |home| - return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); - return null; - } - - if (xdgBase(env)) |base| - return try std.fs.path.join(gpa, &.{ base, "pardes" }); - - const home = nonEmpty(env.get("HOME")) orelse return null; - if (builtin.os.tag == .macos) - return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); - return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); -} - -/// The config directory and its init file: WHERE they were looked for, and -/// what was there. Missing, -/// unreadable, oversized, or otherwise unusable config is simply no config — -/// but the path resolves either way, because "nothing is there yet" is the -/// answer the Config builtin exists to give and a null would erase it. The -/// arena passed by the launcher owns every returned slice for the process. -pub const Found = struct { - dir: ?[]const u8 = null, - path: ?[]const u8 = null, - bytes: ?[]const u8 = null, -}; - -pub fn load( - io: std.Io, - gpa: std.mem.Allocator, - env: *const std.process.Environ.Map, -) Found { - const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; - const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; - return .{ - .dir = config_dir, - .path = config_path, - .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, - }; -} - -fn nonEmpty(value: ?[]const u8) ?[]const u8 { - const v = value orelse return null; - return if (v.len == 0) null else v; -} - -fn xdgBase(env: *const std.process.Environ.Map) ?[]const u8 { - const value = nonEmpty(env.get("XDG_CONFIG_HOME")) orelse return null; - return if (std.fs.path.isAbsolute(value)) value else null; -} - -test "config path honors XDG and rejects a relative XDG directory" { - if (builtin.os.tag == .windows) return; - - var env: std.process.Environ.Map = .init(std.testing.allocator); - defer env.deinit(); - try env.put("HOME", "/home/pardes-test"); - try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); - - const xdg = (try path(std.testing.allocator, &env)).?; - defer std.testing.allocator.free(xdg); - try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); - - try env.put("XDG_CONFIG_HOME", "relative/config"); - const fallback = (try path(std.testing.allocator, &env)).?; - defer std.testing.allocator.free(fallback); - const expected = if (builtin.os.tag == .macos) - "/home/pardes-test/Library/Application Support/pardes" - else - "/home/pardes-test/.config/pardes"; - try std.testing.expectEqualStrings(expected, fallback); -} - -test "config loader reads init inside the config directory" { - if (builtin.os.tag == .windows) return; - - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); - - var env: std.process.Environ.Map = .init(std.testing.allocator); - defer env.deinit(); - try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); - - const missing = load(std.testing.io, std.testing.allocator, &env); - defer std.testing.allocator.free(missing.dir.?); - defer std.testing.allocator.free(missing.path.?); - const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); - defer std.testing.allocator.free(expected_dir); - const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); - defer std.testing.allocator.free(expected); - try std.testing.expectEqualStrings(expected_dir, missing.dir.?); - try std.testing.expectEqualStrings(expected, missing.path.?); - try std.testing.expect(missing.bytes == null); - const source = "Theme dark\nUnknown command\nTheme acme\n"; - try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); - const found = load(std.testing.io, std.testing.allocator, &env); - defer std.testing.allocator.free(found.dir.?); - defer std.testing.allocator.free(found.path.?); - defer std.testing.allocator.free(found.bytes.?); - try std.testing.expectEqualStrings(expected_dir, found.dir.?); - try std.testing.expectEqualStrings(source, found.bytes.?); -} - -/// Write one editable `.zon` file per compiled theme. The caller supplies the -/// ring so this filesystem-only module does not import the core. Existing -/// generated copies are replaced; unrelated files in the directory are left -/// alone, which lets a user keep custom themes beside the reference set. -pub fn dumpThemes( - io: std.Io, - gpa: std.mem.Allocator, - config_dir: []const u8, - theme_values: anytype, -) ![]u8 { - const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); - errdefer gpa.free(out_dir); - try std.Io.Dir.cwd().createDirPath(io, out_dir); - - for (theme_values) |theme_value| { - var encoded: std.Io.Writer.Allocating = .init(gpa); - defer encoded.deinit(); - try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); - - const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); - defer gpa.free(filename); - const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); - defer gpa.free(output_path); - try std.Io.Dir.cwd().writeFile(io, .{ - .sub_path = output_path, - .data = encoded.written(), - }); - } - return out_dir; -} - -test "theme dump creates the builtin subdirectory and ZON files" { - const io = std.testing.io; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var base_buf: [std.fs.max_path_bytes]u8 = undefined; - const base_len = try tmp.dir.realPath(io, &base_buf); - const Sample = struct { name: []const u8, rgb: [3]u8 }; - const samples = [_]Sample{ - .{ .name = "one", .rgb = .{ 1, 2, 3 } }, - .{ .name = "two", .rgb = .{ 4, 5, 6 } }, - }; - const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); - defer gpa.free(output); - const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); - defer gpa.free(expected); - try std.testing.expectEqualStrings(expected, output); - - const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); - defer gpa.free(one_path); - const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); - defer gpa.free(bytes); - const source = try gpa.dupeZ(u8, bytes); - defer gpa.free(source); - const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); - defer std.zon.parse.free(gpa, parsed); - try std.testing.expectEqualStrings("one", parsed.name); - try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); -} diff --git a/src/web.zig b/src/web.zig index bfef38a7..3a9446b6 100644 --- a/src/web.zig +++ b/src/web.zig @@ -105,8 +105,8 @@ const State = struct { frame_rows: u16 = 0, fn init(cols: u16, rows: u16) !State { - const allocs = pardes.allocators.init(gpa); - errdefer pardes.allocators.deinit(); + const allocs = pardes.memory.init(gpa); + errdefer pardes.memory.deinit(); pardes.image.start(std.Io.failing, allocs.image); errdefer pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); @@ -131,7 +131,7 @@ const State = struct { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); - pardes.allocators.deinit(); + pardes.memory.deinit(); } }; @@ -340,12 +340,12 @@ extern "pardes" fn host_download( ) callconv(.c) void; const vtable: pardes.Host.VTable = .{ - .push_present = present, - .push_write_file = writeFile, - .push_write_dump = writeDump, - .push_set_clipboard = setClipboard, - .pull_read_clipboard = readClipboard, - .push_open_link = openLink, + .present = present, + .write_file = writeFile, + .write_dump = writeDump, + .set_clipboard = setClipboard, + .read_clipboard = readClipboard, + .open_link = openLink, }; fn host(s: *State) pardes.Host { diff --git a/test/agent_session.py b/test/agent_session.py new file mode 100644 index 00000000..b920b006 --- /dev/null +++ b/test/agent_session.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +from pathlib import Path +import shlex +import signal +import sys +import tempfile +import time +import uuid + +from fs import session + + +def screen_text(client): + screen = client.screen() + cols = screen['cols'] + return '\n'.join(''.join(cell[0] for cell in screen['cells'][at:at + cols]) + for at in range(0, len(screen['cells']), cols)) + + +def expired(signum, frame): + raise TimeoutError('session deadline exceeded') + + +def wait_ready(client, ready): + previous = None + while True: + text = screen_text(client) + if ready in text and text == previous: + return text + previous = text + time.sleep(.05) + + +def run(args, command, status): + temporary = tempfile.TemporaryDirectory(prefix='pardes-agent-session-') + try: + root = Path(temporary.name) + config = root / 'config' / 'pardes' + config.mkdir(parents=True) + (config / 'init').write_text('Shell /bin/sh\n') + script = root / 'command.sh' + script.write_text('exec ' + shlex.join(command) + '\n') + old_handler = signal.signal(signal.SIGALRM, expired) + signal.setitimer(signal.ITIMER_REAL, args.timeout) + try: + context = session(str(args.binary.resolve()), root, 'agent-session', tty=True, + socket_name='agent-session', launch=['--tty', '--9p=agent-session'], + inherited={'PARDES_TEST_GRID': '1'} if args.gui_grid else {}) + client, _ = context.__enter__() + try: + try: + while not client.read('/self/pane/1/body').strip(): + time.sleep(.01) + started = time.monotonic() + client.write('/self/pane/1/pty/data', shlex.join(['/bin/sh', str(script)]).encode() + b'\r') + status['phase'] = 'readiness' + wait_ready(client, args.ready) + if args.prepare_key is not None: + status['phase'] = 'preparation' + client.write('/self/pane/1/pty/data', args.prepare_key) + wait_ready(client, args.ready) + ready_ms = (time.monotonic() - started) * 1000 + status.update(phase='history', required_body_rows=args.min_rows) + while True: + started = time.monotonic() + body = client.read('/self/pane/1/body') + body_read_ms = (time.monotonic() - started) * 1000 + rows = body.count(b'\n') + int(bool(body) and not body.endswith(b'\n')) + status.update(body_bytes=len(body), body_rows=rows) + if rows >= args.min_rows: + break + time.sleep(.05) + status['phase'] = 'settling' + before = wait_ready(client, args.ready) + status['expected_visible_text_sha256'] = hashlib.sha256(before.encode()).hexdigest() + probe = 'p' + uuid.uuid4().hex[:12] + if probe in before: + raise AssertionError('probe is already visible') + status['phase'] = 'probe-visible' + started = time.monotonic() + client.write('/self/pane/1/pty/data', probe.encode()) + while probe not in screen_text(client).replace('\n', ''): + time.sleep(.01) + write_ms = (time.monotonic() - started) * 1000 + status['phase'] = 'probe-cleared' + started = time.monotonic() + client.write('/self/pane/1/pty/data', b'\x15') + while True: + observed = screen_text(client) + status['observed_visible_text_sha256'] = hashlib.sha256(observed.encode()).hexdigest() + if observed == before: + break + time.sleep(.01) + status['metrics'] = { + 'ready_ms': ready_ms, 'body_read_ms': body_read_ms, + 'ninep_write_to_observation_ms': write_ms, + 'ninep_clear_to_observation_ms': (time.monotonic() - started) * 1000, + 'visible_text_sha256': hashlib.sha256(before.encode()).hexdigest(), + 'body_bytes': len(body), 'body_rows': rows, + 'body_sha256': hashlib.sha256(body).hexdigest(), + } + finally: + previous_phase = status['phase'] + status['phase'] = 'interrupt-cleanup' + signal.setitimer(signal.ITIMER_REAL, 0) + client.socket.settimeout(.25) + for _ in range(2): + try: + client.write('/self/pane/1/pty/data', b'\x03') + except (OSError, EOFError): + pass + time.sleep(.05) + status['phase'] = previous_phase + finally: + previous_phase = status['phase'] + status['phase'] = 'session-cleanup' + context.__exit__(*sys.exc_info()) + status['phase'] = previous_phase + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + finally: + previous_phase = status['phase'] + status['phase'] = 'files-cleanup' + temporary.cleanup() + status['phase'] = previous_phase + return status.pop('metrics') + + +def main(argv): + parser = argparse.ArgumentParser(description='Check an unsubmitted input probe through an owned Pardes 9P session.') + parser.add_argument('binary', type=Path) + parser.add_argument('--ready', required=True, help='visible application readiness text') + parser.add_argument('--timeout', type=float, default=60, help='interaction deadline in seconds, at most 600') + parser.add_argument('--min-rows', type=int, default=0) + parser.add_argument('--prepare-key', help='one hex control byte before history/probe checks; never CR or LF') + parser.add_argument('--gui-grid', action='store_true', help='use the GUI build headless grid host') + if '--' not in argv: + parser.error('supply the interactive command after --') + split = argv.index('--') + args = parser.parse_args(argv[:split]) + command = argv[split + 1:] + if not command or not command[0] or not args.ready.strip(): + parser.error('a command and nonempty --ready text are required') + if not 0 < args.timeout <= 600 or args.min_rows < 0: + parser.error('--timeout must be in (0, 600] and --min-rows must be nonnegative') + if args.prepare_key is not None: + try: + args.prepare_key = bytes.fromhex(args.prepare_key) + except ValueError: + parser.error('--prepare-key must be one hex control byte') + if len(args.prepare_key) != 1 or not 0 < args.prepare_key[0] < 32 or args.prepare_key[0] in (10, 13): + parser.error('--prepare-key must be one control byte other than CR or LF') + status = {'phase': 'startup'} + try: + metrics = run(args, command, status) + except (OSError, EOFError, AssertionError, ValueError) as error: + status['error'] = type(error).__name__ + if isinstance(error, OSError): + status['errno'] = error.errno + print(json.dumps(status), file=sys.stderr) + return 1 + print(json.dumps(metrics)) + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/test/agent_session_test.py b/test/agent_session_test.py new file mode 100644 index 00000000..ba20b5a9 --- /dev/null +++ b/test/agent_session_test.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +import contextlib +import argparse +import errno +import io +import json +from pathlib import Path +import subprocess +import tempfile +import time + +import agent_session + + +def check(binary, options, command, expected, phase=None, cleanup_failure=False): + children = [] + original_popen = subprocess.Popen + + def launch(*args, **kwargs): + child = original_popen(*args, **kwargs) + children.append(child) + return child + + with tempfile.TemporaryDirectory(prefix='pardes-agent-driver-test-') as directory: + original_cleanup = tempfile.TemporaryDirectory.cleanup + + def fail_cleanup(temporary): + original_cleanup(temporary) + raise OSError(errno.ENOTEMPTY, 'injected cleanup failure') + + previous_tempdir = tempfile.tempdir + tempfile.tempdir = directory + subprocess.Popen = launch + if cleanup_failure: + tempfile.TemporaryDirectory.cleanup = fail_cleanup + stdout, stderr = io.StringIO(), io.StringIO() + started = time.monotonic() + try: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + try: + result = agent_session.main([binary, *options, '--', *command]) + except SystemExit as error: + result = error.code + finally: + subprocess.Popen = original_popen + tempfile.tempdir = previous_tempdir + tempfile.TemporaryDirectory.cleanup = original_cleanup + assert result == expected, (result, expected, stderr.getvalue()) + assert all(child.poll() is not None for child in children), 'owned Pardes still running' + assert not list(Path(directory).iterdir()), 'owned session files remain' + if expected == 2: + assert not children, 'invalid arguments started Pardes' + elif expected == 0: + metrics = json.loads(stdout.getvalue()) + assert metrics['body_bytes'] > 0 and metrics['body_rows'] > 0 + assert len(metrics['visible_text_sha256']) == len(metrics['body_sha256']) == 64 + assert metrics['ninep_write_to_observation_ms'] >= 0 + assert metrics['ninep_clear_to_observation_ms'] >= 0 + assert 'AGENT_READY' not in stdout.getvalue() + else: + assert not stdout.getvalue() + error = json.loads(stderr.getvalue()) + assert error['phase'] == phase + if cleanup_failure: + assert error['error'] == 'OSError' and error['errno'] == errno.ENOTEMPTY + assert error['metrics']['body_rows'] > 0 + assert error['expected_visible_text_sha256'] == error['observed_visible_text_sha256'] + else: + assert error['error'] == 'TimeoutError' + assert 'AGENT_READY' not in stderr.getvalue() + assert time.monotonic() - started < 8, 'timeout did not bound cleanup' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('binary', type=Path) + parser.add_argument('--gui-grid', action='store_true') + args = parser.parse_args() + binary = str(args.binary.resolve()) + ready = ['--ready', 'AGENT_READY', *(['--gui-grid'] if args.gui_grid else [])] + command = ['/bin/sh', '-c', 'printf "AGENT_READY> "; read answer'] + for options, argv in [ + ([], command), + (ready + ['--timeout', 'nan'], command), + (ready + ['--timeout', '0'], command), + (ready + ['--timeout', '601'], command), + (ready + ['--min-rows', '-1'], command), + (ready + ['--prepare-key', '0a'], command), + (ready + ['--prepare-key', '0d'], command), + (ready + ['--prepare-key', '41'], command), + (ready + ['--prepare-key', '0f0f'], command), + (ready, []), + ]: + check(binary, options, argv, 2) + check(binary, ready + ['--timeout', '10'], command, 0) + check(binary, ready + ['--timeout', '10', '--min-rows', '1000'], + ['/bin/sh', '-c', 'printf "AGENT_READY> "; sleep .3; seq 1 3000; printf "AGENT_READY> "; read answer'], 0) + check(binary, ready + ['--timeout', '10', '--min-rows', '1000', '--prepare-key', '0f'], + ['/bin/sh', '-c', 'stty raw -echo; printf "AGENT_READY> "; dd bs=1 count=1 of=/dev/null 2>/dev/null; ' + 'stty sane; seq 1 3000; printf "AGENT_READY> "; read answer'], 0) + check(binary, ready + ['--timeout', '2', '--min-rows', '1000'], command, 1, 'history') + check(binary, ready + ['--timeout', '1'], + ['/bin/sh', '-c', ': AGENT_READY; sleep 30'], 1, 'readiness') + check(binary, ready + ['--timeout', '10'], command, 1, 'files-cleanup', cleanup_failure=True) + print('agent-session: 16 functional argument, interaction, timeout and cleanup checks passed') + + +if __name__ == '__main__': + main() diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index e08e59f2..f7f380df 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -14,7 +14,7 @@ extern "c" fn forkpty( ) c_int; extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; /// Monotonic milliseconds; the harness's only clock (std.time lost /// milliTimestamp in 0.16). @@ -88,18 +88,12 @@ pub const Harness = struct { argv[0] = exe; for (args, 0..) |a, i| argv[i + 1] = a; - // The app under test is a fresh outer session even when this harness - // itself is running in a pardes pane. Unlike --nested, the boundary - // still lets pane shells below that app detect and talk to it, which - // nested.snap exercises directly. - var boundary_buf: [32:0]u8 = undefined; - const boundary = try std.fmt.bufPrintSentinel(&boundary_buf, "{d}", .{@as(u32, @intCast(libc.getpid()))}, 0); - if (setenv("PARDES_NESTED_BOUNDARY_PID", boundary, 1) != 0) return error.SetEnvFailed; - var master: c_int = undefined; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { + for ([_][*:0]const u8{ "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }) |name| + if (unsetenv(name) != 0) _exit(126); _ = execvp(exe, argv.ptr); _exit(127); } diff --git a/test/fs.py b/test/fs.py new file mode 100644 index 00000000..9fc5bcc6 --- /dev/null +++ b/test/fs.py @@ -0,0 +1,549 @@ +#!/usr/bin/env python3 +import contextlib +import fcntl +import json +import os +from pathlib import Path +import subprocess +import struct +import sys +import tempfile +import termios +import threading +import time + +from ninep import Client + + +@contextlib.contextmanager +def session(binary, root, name, *options, socket_name=None, file=None, tty=False, network=None, + launch=None, inherited=None): + env = {key: value for key, value in os.environ.items() + if not key.startswith('PARDES_')} + env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), + XDG_CONFIG_HOME=str(root / 'config'), PARDES_NOTIME='1', + PARDES_DUMP=str(root / (name + '.dump.zon'))) + env.update(inherited or {}) + for language in ['RS', 'C', 'GO', 'TS', 'PY']: + env['PARDES_LSP_' + language] = '' + fixture = root / (name + '.txt') + fixture.write_bytes(b'initial\n') + address = root / ('pardes-9p-' + (socket_name or name) + '.sock') + child = None + master = slave = None + reader = None + try: + args = [binary, '--detach=' + name, *options, str(file or fixture)] + preexec = None + if tty: + master, slave = os.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + env['TERM'] = 'xterm-256color' + args = [binary, '--tty', '--9p=' + (socket_name or name), *options, str(file or fixture)] + preexec = lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0) + if launch is not None: + args = [binary, *launch] + child = subprocess.Popen(args, cwd=root, env=env, stdin=slave, + stdout=slave if tty else subprocess.DEVNULL, + stderr=subprocess.PIPE, start_new_session=True, + preexec_fn=preexec) + if launch is not None and socket_name is None: + address = root / f'pardes-9p-{child.pid}.sock' + if slave is not None: + os.close(slave) + slave = None + def drain(): + try: + while os.read(master, 65536): + pass + except OSError: + pass + reader = threading.Thread(target=drain, daemon=True) + reader.start() + deadline = time.monotonic() + 10 + while not address.exists(): + if child.poll() is not None: + raise AssertionError((args, child.returncode, child.stderr.read().decode())) + if time.monotonic() > deadline: + raise TimeoutError('default 9P socket did not appear') + time.sleep(.005) + with Client(address) as client: + if network is not None: + for dial in client.read('/self/listeners').decode().splitlines(): + parts = dial.split('!') + if parts[0] in ['tcp', 'quic']: + assert len(parts) == 3 and 0 < int(parts[2]) < 65536, dial + network[parts[0].upper()] = int(parts[2]) + assert set(network) == {'TCP', 'QUIC'}, network + yield client, address + finally: + if child is not None: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + child.stderr.close() + if slave is not None: + os.close(slave) + if master is not None: + os.close(master) + if reader is not None: + reader.join(timeout=1) + assert not reader.is_alive(), 'terminal reader did not stop' + + +def new_pane(client, contents): + serial = int(client.read('/self/new/ctl').split()[0]) + client.write(f'/self/pane/{serial}/body', contents) + return serial + + +def look(client, path, source=None): + serial = source if source is not None else new_pane(client, b'') + client.write(f'/self/pane/{serial}/body', path.encode(), truncate=True) + client.write(f'/self/pane/{serial}/event', f'ML0 {len(path.encode())}\n'.encode()) + rows = client.read('/self/index').splitlines() + opened = int(rows[-1].split()[0]) + assert opened != serial, f'Look did not open {path}' + return opened + + +def execute(client, serial, command): + text = command.encode() + client.write(f'/self/pane/{serial}/body', text, truncate=True) + client.write(f'/self/pane/{serial}/event', f'MX0 {len(text)}\n'.encode()) + + +def test(binary, quic=False): + started = time.monotonic() + for options, message in [ + (['--fs'], b'no such option'), + (['--fs9'], b'no such option'), + (['--9p'], b'--9p needs a socket name'), + (['--9p='], b'invalid 9P socket name'), + (['--9p=a/b'], b'invalid 9P socket name'), + (['--attach=work', '--9p=work'], b'--attach has none'), + (['--9p-tcp'], b'--9p-tcp needs'), + (['--9p-tcp='], b'--9p-tcp needs'), + (['--9p-tcp=tcp!localhost!5640'], b'--9p-tcp needs'), + (['--9p-tcp=quic!127.0.0.1!5640'], b'--9p-tcp needs'), + (['--9p-tcp=tcp!127.0.0.1!5640', '--9p-tcp=tcp!127.0.0.1!5641'], b'specified twice'), + (['--attach=work', '--9p-tcp=tcp!127.0.0.1!5640'], b'--attach has none'), + (['--9p-quic'], b'--9p-quic needs'), + (['--9p-quic='], b'--9p-quic needs'), + (['--9p-quic=tcp!127.0.0.1!5640'], b'--9p-quic needs'), + (['--mount=os=work'], b'invalid mount name'), + (['--mount=peer='], b'invalid mount name or dial'), + (['--mount=peer=one', '--mount=peer=two'], b'duplicate mount'), + (['--mount=peer=tcp!127.0.0.1!0'], b'invalid mount dial'), + (['--attach=work', '--mount=peer=one'], b'--attach has none'), + ]: + result = subprocess.run([binary, *options], capture_output=True, timeout=5) + assert result.returncode == 1 and message in result.stderr, (options, result) + with tempfile.TemporaryDirectory(prefix='pardes-fs-') as directory: + root = Path(directory) + with session(binary, root, 'renamed', '--9p=explicit', socket_name='explicit') as (named, named_address): + assert named_address.name == 'pardes-9p-explicit.sock' + assert not (root / 'pardes-9p-renamed.sock').exists() + assert named.read('/self/pane/1/body') == b'initial\n' + with session(binary, root, 'first') as (client, address): + assert set(client.list('/')) == {'os', 'self'} + assert {'pane', 'index', 'new'} <= set(client.list('/self')) + assert client.read('/self/pane/1/body') == b'initial\n' + assert client.read('/os' + str(root / 'first.txt')) == b'initial\n' + assert 'first.txt' in client.list('/os' + str(root)) + assert client.read('/self/pane/1/../1/body') == b'initial\n' + assert client.read('/os' + str(root) + '/../' + root.name + '/first.txt') == b'initial\n' + + listing = root / 'listing' + listing.mkdir() + (listing / 'broken').symlink_to(listing / 'missing') + for number in range(24): + (listing / f'entry-{number:02}').touch() + with Client(address, msize=256) as small: + assert set(small.list('/os' + str(listing))) == { + f'entry-{number:02}' for number in range(24)} + + frozen = client.open('/self/screen') + before = bytearray(client.read_fid(frozen, count=31)) + client.write('/self/pane/1/body', b'screen changed\n') + while chunk := client.read_fid(frozen, len(before)): + before.extend(chunk) + client.close(frozen) + old_screen = json.loads(before) + old_text = ''.join(cell[0] for cell in old_screen['cells']) + assert 'initial' in old_text and 'screen changed' not in old_text + assert 'screen changed' in ''.join(cell[0] for cell in client.screen()['cells']) + + payload = ('const value = "λ";\n' * 3000).encode() + serial = new_pane(client, payload) + body = f'/self/pane/{serial}/body' + assert client.read(body) == payload + client.write(body, b'replaced\n', truncate=True) + assert client.read(body) == b'replaced\n' + + event = client.open(f'/self/pane/{serial}/event') + client.write(body, b'event\n') + record = client.read_fid(event) + assert record.startswith(b'EI') and b'event\n' in record, record + client.close(event) + + os_path = '/os' + str(root / 'first.txt') + client.write(os_path, b'written through 9P\n', truncate=True) + assert (root / 'first.txt').read_bytes() == b'written through 9P\n' + assert client.read(os_path) == b'written through 9P\n' + + for index in range(12): + with Client(address) as other: + assert other.read(body) == b'replaced\nevent\n', index + + missing = False + try: + client.read('/self/pane/4294967295/body') + except OSError: + missing = True + assert missing + assert client.read(body) == b'replaced\nevent\n' + + source = look(client, '/virtual/src/pardes.zig') + assert b'pub const Pardes' in client.read(f'/self/pane/{source}/body') + + (root / 'index').write_bytes(b'OS takes precedence\n') + opened = look(client, 'index') + assert client.read(f'/self/pane/{opened}/body') == b'OS takes precedence\n' + opened = look(client, '/n/self/index') + index = client.read(f'/self/pane/{opened}/body') + assert index.split()[0] == b'1' and b'first.txt' in index, index + (root / 'explicit.txt').write_bytes(b'explicit OS mount\n') + opened = look(client, '/n/os' + str(root / 'explicit.txt')) + assert client.read(f'/self/pane/{opened}/body') == b'explicit OS mount\n' + + own = root / 'pardes-9p-second.sock' + with session(binary, root, 'second', '--mount=peer=' + str(address), + '--mount=own=' + str(own)) as (remote, _): + opened = look(remote, '/n/own/self/pane/1/body') + assert remote.read(f'/self/pane/{opened}/body') == b'initial\n' + opened = look(remote, '/n/peer/self/pane/' + str(serial) + '/body') + assert remote.read(f'/self/pane/{opened}/body') == b'replaced\nevent\n' + remote.write(f'/self/pane/{opened}/body', b'saved to peer\n', truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert client.read(body) == b'saved to peer\n' + + large = b'mounted file contents\n' * 60000 + large_path = root / 'large.txt' + large_path.write_bytes(large) + opened = look(remote, '/n/peer/os' + str(large_path)) + assert remote.read(f'/self/pane/{opened}/body') == large + edited = large[:-3] + b'edited\n' + remote.write(f'/self/pane/{opened}/body', edited, truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert large_path.read_bytes() == edited + assert client.read('/os' + str(large_path)) == edited + + with session(binary, root, 'runtime') as (remote, own_address): + control = new_pane(remote, b'') + execute(remote, control, 'Mount peer ' + str(address)) + opened = look(remote, '/n/peer/self/pane/1/body', source=control) + assert remote.read(f'/self/pane/{opened}/body') == client.read('/self/pane/1/body') + execute(remote, control, 'Mount peer ' + str(own_address)) + execute(remote, control, 'Unmount peer') + remote.write(f'/self/pane/{opened}/body', b'runtime mounted Save\n', truncate=True) + remote.write(f'/self/pane/{opened}/ctl', b'put\n') + assert client.read('/self/pane/1/body') == b'runtime mounted Save\n' + assert remote.read('/self/pane/1/body') == b'initial\n' + roots = look(remote, '/n', source=control) + assert b'/n/peer/\n' in remote.read(f'/self/pane/{roots}/body') + remote.write(f'/self/pane/{opened}/ctl', b'delete\n') + execute(remote, control, 'Unmount peer') + remote.write(f'/self/pane/{roots}/ctl', b'get\n') + assert remote.read(f'/self/pane/{roots}/body') == b'/n/os/\n/n/self/\n' + execute(remote, control, 'Mount peer ' + str(own_address)) + opened = look(remote, '/n/peer/self/pane/1/body', source=control) + assert remote.read(f'/self/pane/{opened}/body') == b'initial\n' + + with session(binary, root, 'mounted-startup', '--mount=peer=' + str(address), + file='/n/peer/self/pane/1/body:1') as (remote, _): + assert remote.read('/self/pane/1/body') == b'runtime mounted Save\n' + + client.write(f'/self/pane/{serial}/ctl', b'delete\n') + assert str(serial) not in client.list('/self/pane') + + for tty in [False, True]: + name = 'restore tty space' if tty else 'restore detached space' + with session(binary, root, name, tty=tty) as (old, address): + old.write('/self/pane/1/body', b'dumped state\n', truncate=True) + control = new_pane(old, b'') + execute(old, control, 'Restore ' + str(root / 'missing dump.zon')) + assert old.read('/self/pane/1/body') == b'dumped state\n' + execute(old, control, 'Mount own ' + str(address)) + execute(old, control, 'Dump') + saved = root / (name + '.dump.zon') + deadline = time.monotonic() + 5 + while not saved.exists(): + assert time.monotonic() < deadline, 'Dump did not write its state' + time.sleep(.005) + held_body = old.open('/self/pane/1/body') + held_screen = old.open('/self/screen') + old_ids = {int(row.split()[0]) for row in old.read('/self/index').splitlines()} + assert old.read_fid(held_body) == b'dumped state\n' + assert old.read_fid(held_screen, count=1) == b'{' + old.write('/self/pane/1/body', b'changed after dump\n', truncate=True) + try: + execute(old, control, 'Restore ' + str(saved)) + except (EOFError, ConnectionResetError, BrokenPipeError): + pass + try: + old.read_fid(held_body) + except (EOFError, ConnectionResetError, BrokenPipeError): + pass + else: + raise AssertionError('Restore left an old body fid usable') + with Client(address) as restored: + restored_ids = [int(row.split()[0]) for row in restored.read('/self/index').splitlines()] + assert len(restored_ids) == 2 and old_ids.isdisjoint(restored_ids), restored_ids + file_id, control_id = restored_ids + contents = restored.read(f'/self/pane/{file_id}/body') + assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/self/index')) + assert restored.read(f'/self/pane/{control_id}/body') == b'Dump' + assert restored.screen()['cols'] == 80 + opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id) + assert restored.read(f'/self/pane/{opened}/body') == b'initial\n' + + with session(binary, root, 'forwarding') as (client, address): + child_dir = root / 'child-working-directory' + child_dir.mkdir() + spaced = child_dir / 'space name.txt' + spaced.write_bytes(b'first line\nsecond line\n') + env = os.environ.copy() + env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_FORWARD_LOOK='1') + for word, expected, selected, reuse in [('space name.txt:2:4', spaced.read_bytes(), [14, 14], False), + ('/n/self/pane/1/body', b'initial\n', None, False), + ('/virtual/pane/1/body:1:2-4', b'initial\n', [1, 4], True)]: + before = {int(row.split()[0]) for row in client.read('/self/index').splitlines()} + result = subprocess.run([binary, '--tty', word], cwd=child_dir, env=env, + capture_output=True, timeout=3) + assert result.returncode == 0, (word, result.stderr) + after = {int(row.split()[0]) for row in client.read('/self/index').splitlines()} + if reuse: + assert after == before + else: + opened, = after - before + assert client.read(f'/self/pane/{opened}/body') == expected + assert client.read('/self/pane/1/body') == b'initial\n' + if selected is not None: + addr = client.open(f'/self/pane/{opened}/addr') + client.write(f'/self/pane/{opened}/ctl', b'addr=dot\n') + actual = list(map(int, client.read_fid(addr).split())) + client.close(addr) + assert actual == selected, (word, actual, selected) + addr = client.open('/self/pane/1/addr', 2) + ctl = client.open('/self/pane/1/ctl', 1) + for fid, text in [(addr, b'#0,#3'), (ctl, b'limit=addr\n'), (addr, b'#0')]: + client.rpc(118, struct.pack(' [--quic]') + test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3) diff --git a/test/fs_bench.zig b/test/fs_bench.zig index 7315676a..7fe49734 100644 --- a/test/fs_bench.zig +++ b/test/fs_bench.zig @@ -1,39 +1,15 @@ -//! THE FILESYSTEM SCOREBOARD: what one acme-fs request costs the core, and -//! what serving one costs an editor that nobody is scripting. -//! -//! zig build fs-bench -- the table -//! zig build fs-bench -- --json -- the same, machine-readable -//! zig build fs-bench -- --reps 200000 -- more samples per row -//! -//! There is no FUSE here, and no thread: `acmefs.handle` IS the transaction -//! (src/acmefs.zig), so driving it directly is measuring the whole of what the -//! core does per request. That is the point of the split — a transport adds a -//! `read(2)`, a `write(2)` and a wake, and those are the kernel's numbers, not -//! ours (the mounted end-to-end figures are measured with real clients; see -//! examples/README.md). -//! -//! THREE QUESTIONS THIS ANSWERS. -//! -//! 1. Is a request cheap enough to serve thousands per frame? Each row is -//! one `handle` call, median of `--reps`. -//! 2. Does the steady state ALLOCATE? Every row is measured through a -//! counting allocator and the table prints the allocation count. A -//! non-zero number in a read row is a bug: reads answer with a range of -//! the pane's live text (`Payload.region`) or with the staging buffer, -//! and the staging buffer is cleared, never freed. -//! 3. What does an editor with NO script attached pay? The last two rows are -//! the same keystroke with zero listeners and with one. Zero listeners -//! must be indistinguishable from an editor with no filesystem compiled -//! in at all: one branch in `file_pane.setContent`. const std = @import("std"); const pardes = @import("pardes"); -const acmefs = pardes.acmefs; +const filesystem = pardes.filesystem; pub const std_options: std.Options = .{ .log_level = .err }; const backing = std.heap.page_allocator; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("fs_bench.zig"))}); +}; -/// std.time.Timer is gone in 0.16; clock_gettime is what test/perf.zig uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -43,40 +19,43 @@ fn nowNs() u64 { const Row = struct { name: []const u8, ns: u64, + reps: usize, allocs: usize, note: []const u8 = "", }; var rows: std.ArrayList(Row) = .empty; -fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) void { - rows.append(backing, .{ +fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) !void { + try rows.append(backing, .{ .name = name, - .ns = total_ns / @max(1, reps), + .ns = total_ns / reps, + .reps = reps, .allocs = counting.allocations, .note = note, - }) catch {}; + }); } -/// A session with something to measure against: one big file pane, one shell, -/// and a scripted pane whose event queue has records waiting. const Session = struct { core: *pardes.Pardes, counting: *std.testing.FailingAllocator, file_id: usize, file_serial: u32, + body_bytes: usize, + tag: []u8, fn init(counting: *std.testing.FailingAllocator, body_bytes: usize) !Session { const gpa = counting.allocator(); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 120, .rows = 40 }); + errdefer core.deinit(); while (core.nextEffect()) |_| {} - // A body big enough that a copy would show up in the numbers. const line = "the quick brown fox jumps over the lazy dog\n"; var content: std.ArrayList(u8) = .empty; - while (content.items.len < body_bytes) try content.appendSlice(backing, line); - const pane = try core.hxOpenFileContent(content.items); - content.deinit(backing); + defer content.deinit(backing); + while (content.items.len < body_bytes) + try content.appendSlice(backing, line[0..@min(line.len, body_bytes - content.items.len)]); + const pane = try core.setTestFile(content.items); const id = core.active; while (core.nextEffect()) |_| {} return .{ @@ -84,120 +63,208 @@ const Session = struct { .counting = counting, .file_id = id, .file_serial = pane.serial, + .body_bytes = pane.file.?.content.len, + .tag = try core.tagText(backing, pane), }; } fn deinit(s: *Session) void { + backing.free(s.tag); s.core.deinit(); } - fn node(s: *const Session, file: acmefs.PaneFile) u64 { - return acmefs.Node.of(s.file_serial, file); + fn node(s: *const Session, file: filesystem.PaneFile) u64 { + return filesystem.Node.of(s.file_serial, file); } }; -/// One row: run `req` `reps` times and report the mean cost plus how many -/// allocations the whole run made. -/// -/// The per-update scratch arena is reset each rep because that is what the -/// real path does — `Pardes.update` resets it at the end of every event, and -/// `handle` called bare would otherwise let one arena grow across a hundred -/// thousand requests and count its CHUNKS as allocations. Resetting here -/// measures the request, not the harness. -fn bench(s: *Session, name: []const u8, reps: usize, req: acmefs.Req, note: []const u8) void { - // warm the staging buffer and any lazy index the first call builds +fn checkNumbers(bytes: []const u8, expected: []const usize) ![]const u8 { + var rest = bytes; + for (expected) |value| { + rest = std.mem.trimStart(u8, rest, " "); + const end = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.IncorrectReply; + const got = std.fmt.parseInt(usize, rest[0..end], 10) catch return error.IncorrectReply; + if (got != value) return error.IncorrectReply; + rest = rest[end + 1 ..]; + } + return std.mem.trimStart(u8, rest, " "); +} + +fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void { + const event_read = req.op == .read and req.node == s.node(.event); + const expected_status: filesystem.Status = if (event_read) .again else .ok; + if (reply.tag != req.tag or reply.errno != 0 or reply.status != expected_status) + return error.IncorrectReply; + const body = s.core.panes[s.file_id].?.file.?.content; + switch (req.op) { + .getattr => if (reply.attr.node != req.node or reply.attr.size != body.len or reply.attr.dir) return error.IncorrectReply, + .lookup => if (reply.attr.node != s.node(.ctl) or reply.attr.dir) return error.IncorrectReply, + .read => { + if (event_read) { + if (reply.payload != .none) return error.IncorrectReply; + } else if (req.node == s.node(.body)) { + if (reply.payload != .region) return error.IncorrectReply; + const region = reply.payload.region; + if (region.pane != s.file_id or region.serial != s.file_serial or region.off != req.off or region.len != req.size) + return error.IncorrectReply; + } else { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const pane = s.core.panes[s.file_id].?; + const rest = try checkNumbers(bytes, &.{ s.file_serial, s.tag.len, body.len, 0, @intFromBool(pane.file.?.revision != pane.file.?.saved_revision) }); + if (req.node == s.node(.ctl)) { + const tail = try checkNumbers(rest, &.{pane.cols}); + if (!std.mem.startsWith(u8, tail, "default ")) return error.IncorrectReply; + if ((try checkNumbers(tail[8..], &.{pardes.config.tab_width})).len != 0) + return error.IncorrectReply; + } else if (req.node == @intFromEnum(filesystem.SelfFile.index)) { + const tag = s.tag[0 .. std.mem.indexOfScalar(u8, s.tag, '\n') orelse s.tag.len]; + if (rest.len != tag.len + 1 or rest[rest.len - 1] != '\n' or !std.mem.eql(u8, rest[0..tag.len], tag)) + return error.IncorrectReply; + } else return error.UnmeasuredOperation; + } + }, + .readdir => { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const names = [_][]const u8{ "index", "cons", "new", "pane", "screen", "listeners" }; + const nodes = [_]u64{ @intFromEnum(filesystem.SelfFile.index), @intFromEnum(filesystem.SelfFile.cons), @intFromEnum(filesystem.SelfFile.new), filesystem.namespace_panes, @intFromEnum(filesystem.SelfFile.screen), @intFromEnum(filesystem.SelfFile.listeners) }; + var off: usize = 0; + var entry: usize = 0; + while (off < bytes.len) : (entry += 1) { + if (bytes.len - off < 10) return error.IncorrectReply; + const node = std.mem.readInt(u64, bytes[off..][0..8], .little); + const dir = bytes[off + 8]; + const size: usize = bytes[off + 9]; + off += 10; + if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply; + if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry == 2 or entry == 3) or + !std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply; + off += size; + } + if (entry < names.len) return error.IncorrectReply; + }, + .write => if (reply.written != req.data.len or !std.mem.endsWith(u8, body, req.data)) return error.IncorrectReply, + else => return error.UnmeasuredOperation, + } +} + +fn bench(s: *Session, name: []const u8, reps: usize, req: filesystem.Req, note: []const u8) !void { + std.debug.assert(reps > 0); + const initial_bytes = s.core.panes[s.file_id].?.file.?.content.len; + const initial_hash = if (req.op == .write) std.hash.Wyhash.hash(0, s.core.panes[s.file_id].?.file.?.content) else 0; for (0..64) |_| { - _ = acmefs.handle(s.core, req); + try checkReply(s, req, filesystem.handle(s.core, req)); _ = s.core.scratch.reset(.retain_capacity); } s.counting.allocations = 0; + const expected_status: filesystem.Status = if (req.op == .read and req.node == s.node(.event)) .again else .ok; + var valid = true; + var reply: filesystem.Reply = undefined; const start = nowNs(); for (0..reps) |_| { - const reply = acmefs.handle(s.core, req); - std.mem.doNotOptimizeAway(reply.status); + reply = filesystem.handle(s.core, req); + valid = valid and reply.tag == req.tag and reply.status == expected_status and reply.errno == 0; + std.mem.doNotOptimizeAway(reply); _ = s.core.scratch.reset(.retain_capacity); } - record(name, nowNs() - start, reps, s.counting, note); + const elapsed = nowNs() - start; + if (!valid) return error.IncorrectReply; + try checkReply(s, req, reply); + if (req.op == .write) { + const body = s.core.panes[s.file_id].?.file.?.content; + if (body.len != initial_bytes + (64 + reps) * req.data.len or + std.hash.Wyhash.hash(0, body[0..initial_bytes]) != initial_hash or + !std.mem.allEqual(u8, body[initial_bytes..], 'x')) return error.MissingWrite; + } + try record(name, elapsed, reps, s.counting, note); } -pub fn main(init: std.process.Init) !void { - const args = try init.minimal.args.toSlice(init.arena.allocator()); - var reps: usize = 100_000; - var json = false; - var i: usize = 1; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--json")) { - json = true; - } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) { - i += 1; - reps = try std.fmt.parseInt(usize, args[i], 10); +const Options = struct { + reps: usize = 100_000, + json: bool = false, + + fn parse(args: []const []const u8) !Options { + var result: Options = .{}; + var seen_reps = false; + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--json") and !result.json) { + result.json = true; + } else if (std.mem.eql(u8, args[i], "--reps") and !seen_reps) { + i += 1; + if (i == args.len) return error.MissingRepetitions; + result.reps = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidRepetitions; + if (result.reps == 0) return error.InvalidRepetitions; + seen_reps = true; + } else return error.InvalidArgument; } + return result; } +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const options = try Options.parse(args[1..]); + const reps = options.reps; + defer rows.deinit(backing); - // std.testing.FailingAllocator with the default options never induces a - // failure and counts every allocation, which is the whole of what this - // benchmark wanted from a wrapper. var counting: std.testing.FailingAllocator = .init(backing, .{}); - var s = try Session.init(&counting, 1 << 20); // a 1 MiB body + var s = try Session.init(&counting, 1 << 20); defer s.deinit(); - // ---- the three shapes of request ------------------------------------- - bench(&s, "getattr body", reps, .{ + try bench(&s, "getattr body", reps, .{ .tag = 1, .op = .getattr, .node = s.node(.body), }, "stat of a 1 MiB body"); - bench(&s, "lookup ctl", reps, .{ + try bench(&s, "lookup ctl", reps, .{ .tag = 2, .op = .lookup, - .node = acmefs.Node.of(s.file_serial, .dir), + .node = filesystem.Node.of(s.file_serial, .dir), .data = "ctl", }, "name -> node"); - bench(&s, "read body 4K", reps, .{ + try bench(&s, "read body 4K", reps, .{ .tag = 3, .op = .read, .node = s.node(.body), .off = 4096, .size = 4096, }, "must be zero-copy"); - bench(&s, "read body 1M", reps / 10, .{ + try bench(&s, "read body 1M", @max(1, reps / 10), .{ .tag = 4, .op = .read, .node = s.node(.body), .off = 0, .size = 1 << 20, }, "same cost as 4K if truly zero-copy"); - bench(&s, "read ctl", reps, .{ + try bench(&s, "read ctl", reps, .{ .tag = 5, .op = .read, .node = s.node(.ctl), .size = 256, }, "formatted into the staging buffer"); - bench(&s, "read index", reps, .{ + try bench(&s, "read index", reps, .{ .tag = 6, .op = .read, - .node = @intFromEnum(acmefs.TopFile.index), + .node = @intFromEnum(filesystem.SelfFile.index), .size = 4096, }, "one line per pane"); - bench(&s, "readdir root", reps, .{ + try bench(&s, "readdir self", reps, .{ .tag = 7, .op = .readdir, - .node = @intFromEnum(acmefs.TopFile.root), + .node = @intFromEnum(filesystem.SelfFile.root), .size = 4096, }, "staged dirents"); - bench(&s, "read event (empty)", reps, .{ + try bench(&s, "read event (empty)", reps, .{ .tag = 8, .op = .read, .node = s.node(.event), .size = 256, }, "Status.again — the blocking primitive"); - // Appending GROWS the fixture, and every append is a whole-body swap plus - // an undo snapshot (that is the core's edit model, not this filesystem's), - // so this row is quadratic in its own rep count against a 1 MiB body. - // Bounded on purpose: the question is what one write costs, and 200 of - // them answer it without spending a quarter of an hour proving that - // appending ten megabytes a kilobyte at a time is slow. - bench(&s, "write body 1K", @min(reps, 200), .{ + try bench(&s, "write body 1K", @min(reps, 200), .{ .tag = 9, .op = .write, .node = s.node(.body), @@ -205,25 +272,15 @@ pub fn main(init: std.process.Init) !void { .size = 1024, }, "append: whole-body swap + undo snapshot"); - // ---- what an unscripted editor pays ---------------------------------- - // The same keystroke, twice: with nobody listening and with one listener. - // The first number is the honest answer to "what does this feature cost a - // session that never uses it", and the pair is what recording costs. - // - // A FRESH SESSION PER ROW, and this matters: typing inserts at the cursor, - // so the line under it grows by one character per rep, and the core's - // per-keystroke cost is dominated by walking that line's grapheme widths - // (measured: `file_pane.graphemeDisplayWidth` is 65% of this benchmark's - // cycles). Reusing one session made the second row type into a body the - // first had already lengthened, and reported a 2.8x "overhead" that was - // entirely the fixture. Small bodies for the same reason: on the megabyte - // fixture a keystroke costs ~40 ms whatever this filesystem does. const key_reps = @min(reps, 2000); + var key_body_bytes: usize = 0; for ([_]bool{ false, true }) |scripted| { var keys = try Session.init(&counting, 32 * 1024); defer keys.deinit(); const pane = keys.core.panes[keys.file_id].?; pane.mode = .insert; + const initial_bytes = pane.file.?.content.len; + key_body_bytes = initial_bytes; if (scripted) { keys.core.fs.panes[keys.file_id].readers = 1; keys.core.fs.listeners = 1; @@ -234,9 +291,20 @@ pub fn main(init: std.process.Init) !void { keys.core.update(.{ .key = .{ .cp = 'x', .text = "x" } }); while (keys.core.nextEffect()) |_| {} } - record( + const elapsed = nowNs() - start; + if (pane.file.?.content.len != initial_bytes + key_reps or pane.cur_col != key_reps or + !std.mem.allEqual(u8, pane.file.?.content[0..key_reps], 'x')) return error.MissingKeystroke; + const events = &keys.core.fs.panes[keys.file_id].events; + if (scripted) for (0..key_reps) |i| { + var buf: [64]u8 = undefined; + const expected = try std.fmt.bufPrint(&buf, "KI{d} {d} 0 1 x\n", .{ i, i + 1 }); + if (!std.mem.eql(u8, expected, events.peek() orelse return error.MissingEvent)) return error.IncorrectEvent; + events.pop(); + }; + if (!events.empty()) return error.IncorrectEvent; + try record( if (scripted) "keystroke, 1 listener" else "keystroke, no listener", - nowNs() - start, + elapsed, key_reps, &counting, if (scripted) "diff + record" else "one branch", @@ -246,11 +314,11 @@ pub fn main(init: std.process.Init) !void { var out: std.Io.Writer.Allocating = .init(backing); defer out.deinit(); const w = &out.writer; - if (json) { - try w.writeAll("{\"rows\":["); + if (options.json) { + try w.print("{{\"harness\":\"{s}\",\"body_bytes\":{d},\"key_body_bytes\":{d},\"rows\":[", .{ harness_id, s.body_bytes, key_body_bytes }); for (rows.items, 0..) |r, n| { if (n > 0) try w.writeAll(","); - try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.allocs }); + try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"reps\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.reps, r.allocs }); } try w.writeAll("]}\n"); } else { @@ -261,3 +329,20 @@ pub fn main(init: std.process.Init) !void { } try std.Io.File.stdout().writeStreamingAll(init.io, out.written()); } + +test "fs benchmark rejects empty workloads and ambiguous options" { + try std.testing.expectEqual(@as(usize, 100_000), (try Options.parse(&.{})).reps); + try std.testing.expectEqualDeep(Options{ .reps = 1, .json = true }, try Options.parse(&.{ "--json", "--reps", "1" })); + try std.testing.expectError(error.MissingRepetitions, Options.parse(&.{"--reps"})); + for ([_][]const u8{ "0", "-1", "no", "99999999999999999999999999" }) |value| + try std.testing.expectError(error.InvalidRepetitions, Options.parse(&.{ "--reps", value })); + for ([_][]const []const u8{ &.{"--typo"}, &.{ "--json", "--json" }, &.{ "--reps", "1", "--reps", "2" } }) |args| + try std.testing.expectError(error.InvalidArgument, Options.parse(args)); +} + +test "fs benchmark rejects wrong or incomplete staged metadata" { + try std.testing.expectEqualStrings("/test.txt\n", try checkNumbers(" 2 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1023 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("3 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1024 ", &.{ 2, 9, 1024, 0, 0 })); +} diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig new file mode 100644 index 00000000..c856148d --- /dev/null +++ b/test/fs_namespace.zig @@ -0,0 +1,333 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const filesystem = pardes.filesystem; + +test "runtime Mount and Unmount copy command arguments and change Look resolution" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + var command = "Mount peer /tmp/a socket.sock".*; + try std.testing.expect(p.executeBuiltinLine(0, &command)); + @memset(&command, 'x'); + try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len); + try std.testing.expectEqualStrings("peer", p.fs.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial); + var path: [4096]u8 = undefined; + try std.testing.expectEqualStrings("/n/peer/self/index", filesystem.resolve(p, "/n/peer/self/index", "/", &path).?.path); + try std.testing.expect(p.executeBuiltinLine(0, "Mount peer /tmp/other.sock")); + try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len); + try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial); + try std.testing.expect(p.executeBuiltinLine(0, "Unmount peer")); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + try std.testing.expect(filesystem.resolve(p, "/n/peer/self/index", "/", &path) == null); + for ([_][]const u8{ "Mount", "Mount name", "Mount os /tmp/peer.sock", "Mount peer tcp!127.0.0.1!0", "Unmount", "Unmount name extra" }) |invalid| { + try std.testing.expect(p.executeBuiltinLine(0, invalid)); + try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + } +} + +test "mounts survive dumps without borrowing inputs or silently overriding a saved target" { + const gpa = std.testing.allocator; + var name = "peer".*; + var dial = "/tmp/peer.sock".*; + const startup = [_]filesystem.Mount{.{ .name = &name, .dial = &dial }}; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .mounts = &startup }); + defer p.deinit(); + @memset(&name, 'x'); + @memset(&dial, 'x'); + try std.testing.expectEqual(@as(usize, 0), p.opts.mounts.len); + const file = try p.setTestFile("saved remote body\n"); + file.file.?.revision = 3; + gpa.free(file.file.?.path); + file.file.?.path = try gpa.dupe(u8, "/n/peer/os/file.txt"); + try p.dumpState(); + const restored = try pardes.Pardes.initFromDump(gpa, .{}, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, 1), restored.fs.mounts.items.len); + try std.testing.expectEqualStrings("peer", restored.fs.mounts.items[0].name); + try std.testing.expectEqualStrings("/tmp/peer.sock", restored.fs.mounts.items[0].dial); + try std.testing.expectEqualStrings("/n/peer/os/file.txt", restored.panes[0].?.file.?.path); + try std.testing.expectEqualStrings("saved remote body\n", restored.panes[0].?.file.?.content); + try std.testing.expect(restored.panes[0].?.file.?.revision != restored.panes[0].?.file.?.saved_revision); + try std.testing.expectError(error.MountInUse, filesystem.unmount(restored, "peer")); + const same = try pardes.Pardes.initFromDump(gpa, .{ .mounts = &.{.{ .name = "peer", .dial = "/tmp/peer.sock" }} }, p.dump_out.?); + defer same.deinit(); + try std.testing.expectEqual(@as(usize, 1), same.fs.mounts.items.len); + try std.testing.expectError(error.MountConflict, pardes.Pardes.initFromDump(gpa, .{ + .mounts = &.{.{ .name = "peer", .dial = "/tmp/different.sock" }}, + }, p.dump_out.?)); + const added = try pardes.Pardes.initFromDump(gpa, .{ .mounts = &.{.{ .name = "other", .dial = "/tmp/other.sock" }} }, p.dump_out.?); + defer added.deinit(); + try std.testing.expectEqual(@as(usize, 2), added.fs.mounts.items.len); +} + +test "removed startup mounts are not resurrected by dump restore" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ + .tty_only = true, + .mounts = &.{.{ .name = "peer", .dial = "/tmp/peer.sock" }}, + }); + defer p.deinit(); + try filesystem.unmount(p, "peer"); + try p.dumpState(); + const restored = try pardes.Pardes.initFromDump(gpa, p.opts, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, 0), restored.fs.mounts.items.len); +} + +test "transactional restore preserves host capabilities and advances pane identities" { + const gpa = std.testing.allocator; + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 91, .rows = 37 }); + defer p.deinit(); + const pane = try p.setTestFile("before\n"); + p.lsp_seq = 41; + p.pipe_seq = 73; + if (@hasField(pardes.CellPixels, "w")) p.cell_pixels = .{ .w = 9, .h = 18 }; + p.native_images = true; + p.fs.socket_path = "/tmp/session.sock"; + p.fs.tcp_address = .{ .ip4 = .loopback(5640) }; + p.fs.quic_address = .{ .ip6 = .loopback(5641) }; + try p.dumpState(); + const restored = try p.restore(p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(u32, 41), restored.lsp_seq); + try std.testing.expectEqual(@as(u32, 73), restored.pipe_seq); + try std.testing.expectEqual(p.cell_pixels, restored.cell_pixels); + try std.testing.expect(restored.native_images); + try std.testing.expect(restored.panes[0].?.serial > p.next_serial); + try std.testing.expect(restored.paneBySerial(pane.serial) == null); + try std.testing.expectEqual(p.screen_w, restored.screen_w); + try std.testing.expectEqual(p.screen_h, restored.screen_h); + try std.testing.expectEqualStrings(p.fs.socket_path, restored.fs.socket_path); + try std.testing.expectEqualDeep(p.fs.tcp_address, restored.fs.tcp_address); + try std.testing.expectEqualDeep(p.fs.quic_address, restored.fs.quic_address); + try std.testing.expect(p.panes[0] == pane); + try std.testing.expectEqualStrings("before\n", pane.file.?.content); + try std.testing.expectError(error.BadDumpMagic, p.restore( + ".{ .magic = \"wrong\", .screen = .{.cols = 80, .rows = 24} }", + )); + try std.testing.expect(p.panes[0] == pane); + try std.testing.expectEqualStrings("before\n", pane.file.?.content); + try std.testing.expectEqual(@as(u32, 41), p.lsp_seq); + try std.testing.expectEqualStrings("/tmp/session.sock", p.fs.socket_path); +} + +test "same-session identity is available before an initial mounted file is opened" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file.txt", .data = "no socket required\n" }); + var buf: [4096]u8 = undefined; + const len = try tmp.dir.realPathFile(std.testing.io, "file.txt", &buf); + const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{buf[0..len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ + .file = path, + .mounts = &.{.{ .name = "own", .dial = "/tmp/not-listening.sock" }}, + .ninep_identity = .{ .socket_path = "/tmp/not-listening.sock" }, + }); + defer p.deinit(); + try std.testing.expectEqualStrings(path, p.panes[0].?.file.?.path); + try std.testing.expectEqualStrings("no socket required\n", p.panes[0].?.file.?.content); + try std.testing.expectEqualStrings("", p.opts.ninep_identity.socket_path); +} + +test "filesystem resolves OS first then virtual with explicit mounts and parent paths" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "index", .data = "OS INDEX\n" }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const directory = directory_buffer[0..directory_len]; + const os_index = try std.fmt.allocPrint(gpa, "{s}/index", .{directory}); + defer gpa.free(os_index); + const explicit_os = try std.fmt.allocPrint(gpa, "/n/os{s}", .{os_index}); + defer gpa.free(explicit_os); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("SELF BODY\n"); + const explicit_self = try std.fmt.allocPrint(gpa, "/n/self/pane/{d}/body", .{pane.serial}); + defer gpa.free(explicit_self); + const virtual_self = try std.fmt.allocPrint(gpa, "/virtual/pane/{d}/body", .{pane.serial}); + defer gpa.free(virtual_self); + const parent_self = try std.fmt.allocPrint(gpa, "/n/self/pane/{d}/../{d}/body", .{ pane.serial, pane.serial }); + defer gpa.free(parent_self); + + for ([_]struct { word: []const u8, path: []const u8, bytes: []const u8 }{ + .{ .word = "index", .path = os_index, .bytes = "OS INDEX\n" }, + .{ .word = os_index, .path = os_index, .bytes = "OS INDEX\n" }, + .{ .word = explicit_os, .path = explicit_os, .bytes = "OS INDEX\n" }, + .{ .word = explicit_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + .{ .word = virtual_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + .{ .word = parent_self, .path = virtual_self, .bytes = "SELF BODY\n" }, + }) |case| { + var resolved_buffer: [4096]u8 = undefined; + const found = filesystem.resolve(p, case.word, directory, &resolved_buffer) orelse return error.UnresolvedPath; + try std.testing.expectEqualStrings(case.path, found.path); + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expectEqualStrings(case.bytes, bytes); + } + + for ([_][]const u8{ "/n/self/index", "/virtual/index" }) |word| { + var resolved_buffer: [4096]u8 = undefined; + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.UnresolvedPath; + try std.testing.expectEqualStrings("/virtual/index", found.path); + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "/test.txt") != null); + } + try tmp.dir.deleteFile(std.testing.io, "index"); + var resolved_buffer: [4096]u8 = undefined; + const fallback = filesystem.resolve(p, "index", directory, &resolved_buffer) orelse return error.MissingVirtualFallback; + try std.testing.expectEqualStrings("/virtual/index", fallback.path); + + for ([_][]const u8{ "src/pardes.zig", "/virtual/src/pardes.zig", "/virtual/src/../src/pardes.zig" }) |word| { + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.MissingEmbeddedSource; + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "pub const Pardes") != null); + } + for ([_][]const u8{ "/virtual/src", "/n/self/src" }) |word| { + const found = filesystem.resolve(p, word, directory, &resolved_buffer) orelse return error.MissingEmbeddedDirectory; + const bytes = try filesystem.read(p, found.path); + defer gpa.free(bytes); + try std.testing.expect(std.mem.indexOf(u8, bytes, "/virtual/src/pardes.zig\n") != null); + } +} + +test "explicit OS file opens and restores keep their names and request watches" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "watched.zig", .data = "const disk = 1;\n" }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/watched.zig", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const opened = p.freeSlot().?; + const pane = try pardes.panes.File.open(p, opened, path, 0); + try std.testing.expectEqualStrings(path, pane.file.?.path); + try std.testing.expectEqualStrings("const disk = 1;\n", pane.file.?.content); + const restored = p.freeSlot().?; + const copy = try pardes.panes.File.restore(p, restored, .{ + .kind = .file, + .tag = path, + .body = "", + .file = .{ .path = path, .content = "const unsaved = 2;\n" }, + }); + try std.testing.expectEqualStrings(path, copy.file.?.path); + try std.testing.expectEqualStrings("const unsaved = 2;\n", copy.file.?.content); + var watched_open = false; + var watched_restore = false; + while (p.nextEffect()) |effect| if (effect == .watch and effect.watch.on) { + if (effect.watch.pane == opened) watched_open = true; + if (effect.watch.pane == restored) watched_restore = true; + }; + try std.testing.expect(watched_open and watched_restore); +} + +test "virtual file opens do not request OS watches" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const free = p.freeSlot().?; + _ = try pardes.panes.File.open(p, free, "/virtual/src/pardes.zig", 0); + while (p.nextEffect()) |effect| if (effect == .watch) return error.VirtualFileWatch; +} + +test "Look at the self factory reserves its own pane and failed opens release it" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.lookAt(0, "/virtual/new/ctl"); + var control_count: usize = 0; + var scratch_count: usize = 0; + for (p.panes) |slot| { + const pane = slot orelse continue; + const file = pane.file orelse continue; + if (std.mem.eql(u8, file.path, "/virtual/new/ctl")) control_count += 1; + if (file.output) |output| if (output.from == .cmd and output.from.cmd == .New) { + scratch_count += 1; + }; + } + try std.testing.expectEqual(@as(usize, 1), control_count); + try std.testing.expectEqual(@as(usize, 1), scratch_count); + const free = p.freeSlot().?; + try std.testing.expectError(error.FileNotFound, pardes.panes.File.open(p, free, "/n/self/does-not-exist", 0)); + try std.testing.expect(p.panes[free] == null); + try std.testing.expect(!p.reserved_slots[free]); + _ = try p.newDocPane(free); +} + +test "owned cwd failed file opens never publish a pane or retain a reservation" { + var succeeded = false; + for (0..16) |failure| { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try pardes.Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("source body\n"); + p.setCwd(0, "/source/directory"); + while (p.nextEffect()) |_| {} + var path: [128]u8 = undefined; + const name = try std.fmt.bufPrint(&path, "/virtual/pane/{d}/body", .{source.serial}); + const slot = p.freeSlot().?; + const serial = p.next_serial; + const columns = p.col_panes; + const counts = p.col_n; + allocator.fail_index = allocator.alloc_index + failure; + const opened = pardes.panes.File.open(p, slot, name, 0) catch |err| { + try std.testing.expect(err == error.OutOfMemory or err == error.ReadFailed or err == error.WriteFailed); + try std.testing.expect(p.panes[slot] == null); + try std.testing.expect(!p.reserved_slots[slot]); + try std.testing.expectEqual(serial, p.next_serial); + try std.testing.expectEqual(slot, p.freeSlot().?); + try std.testing.expectEqualDeep(columns, p.col_panes); + try std.testing.expectEqualDeep(counts, p.col_n); + try std.testing.expectEqual(@as(usize, 0), p.effects_len); + try std.testing.expectEqualStrings("/source/directory", source.cwdSlice()); + allocator.fail_index = std.math.maxInt(usize); + _ = try p.newDocPane(slot); + continue; + }; + try std.testing.expect(!p.reserved_slots[slot]); + try std.testing.expectEqualStrings("source body\n", opened.file.?.content); + succeeded = true; + break; + } + try std.testing.expect(succeeded); +} + +test "images decode and dump through explicit filesystem mounts" { + if (!filesystem.platform_has_fs) return error.SkipZigTest; + const gpa = std.testing.allocator; + pardes.image.start(std.testing.io, gpa); + defer pardes.image.stop(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = "P6\n1 1\n255\n\x0d\x4d\xe7"; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pixel.ppm", .data = bytes }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/pixel.ppm", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .file = path }); + defer p.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + _ = try p.render(arena.allocator()); + const pane = p.panes[0].?; + const state = pane.image.?; + try std.testing.expectEqual(@as(usize, 1), state.iw); + try std.testing.expectEqual(@as(usize, 1), state.ih); + const saved = try pardes.panes.Image.dumpPane(p, arena.allocator(), pane, "", "", 0, path, &.{}); + const decoded = try pardes.dump.decodeBytes(gpa, saved.image.?.bytes_b64); + defer gpa.free(decoded); + try std.testing.expectEqualStrings(bytes, decoded); +} diff --git a/test/fs_soak.py b/test/fs_soak.py new file mode 100644 index 00000000..1201fda2 --- /dev/null +++ b/test/fs_soak.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +from pathlib import Path +import random +import socket +import struct +import tempfile +import time + +from fs import new_pane, session +from ninep import Client + + +BODY_LIMIT = 4096 +SCREEN_LIMIT = 4 * 1024 * 1024 +FRAGMENTS = [ + b'const value = 42;\n', b'fn check() void { if (true) return; }\n', + b'\tspaces and tabs\n', 'λ 界 e\u0301 👩\u200d🚀\n'.encode(), b'x', +] +OPERATIONS = ['append', 'range', 'replace', 'read', 'save', 'reload', + 'cycle', 'reconnect', 'screen'] + + +def emit(**record): + print(json.dumps(record, separators=(',', ':')), flush=True) + + +def payload(rng, limit=256): + result = bytearray() + for _ in range(rng.randint(1, 12)): + fragment = rng.choice(FRAGMENTS) + if len(result) + len(fragment) <= limit: + result.extend(fragment) + return bytes(result) or b'x'[:limit] + + +def check_bytes(actual, expected, label): + if actual == expected: + return + at = next((i for i, pair in enumerate(zip(actual, expected)) + if pair[0] != pair[1]), min(len(actual), len(expected))) + raise AssertionError(f'{label}: mismatch at byte {at}; ' + f'length {len(actual)} != {len(expected)}; ' + f'{actual[at:at + 32]!r} != {expected[at:at + 32]!r}') + + +def read_fid(client, fid, limit, count): + result = bytearray() + while chunk := client.read_fid(fid, len(result), count): + result.extend(chunk) + if len(result) > limit: + raise AssertionError(f'read exceeded fixture limit {limit}') + return bytes(result) + + +def check_body(client, pane, count=4096): + fid = client.open(f'/self/pane/{pane["serial"]}/body') + try: + actual = read_fid(client, fid, BODY_LIMIT, count) + finally: + client.close(fid) + check_bytes(actual, pane['body'], f'pane {pane["serial"]}') + + +def check_screen(data): + screen = json.loads(data) + cols, rows = screen['cols'], screen['rows'] + assert cols > 0 and rows > 0 and cols * rows <= 65536, (cols, rows) + assert len(screen['cells']) == cols * rows + styles = screen['styles'] + assert styles + for cell in screen['cells']: + assert len(cell) == 2 and isinstance(cell[0], str), cell + assert isinstance(cell[1], int) and 0 <= cell[1] < len(styles), cell + + +def session_pid(client): + if not hasattr(socket, 'SO_PEERCRED'): + return None + pid, uid, _ = struct.unpack('3i', client.socket.getsockopt( + socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))) + assert pid > 0 and uid == os.getuid(), (pid, uid) + return pid + + +def check_session(client, control, pid): + assert session_pid(control) == pid + if pid is not None: + actual = (session_pid(client) if client.socket.family == socket.AF_UNIX + else int(client.read('/os/proc/self/stat').split()[0])) + assert actual == pid, (actual, pid) + + +def memory(pid): + result = {'rss_bytes': None, 'cumulative_peak_rss_bytes': None} + if pid is None: + return result + try: + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + fields = line.split() + if fields[0] == 'VmRSS:': + result['rss_bytes'] = int(fields[1]) * 1024 + elif fields[0] == 'VmHWM:': + result['cumulative_peak_rss_bytes'] = int(fields[1]) * 1024 + except OSError: + pass + return result + + +def run(args): + rng = random.Random(args.seed) + started = time.monotonic() + deadline = None + batch = 0 + operation = 'start' + operation_count = 0 + pid = None + try: + with tempfile.TemporaryDirectory(prefix='pardes-soak-') as directory: + root = Path(directory) + options = ['--9p-tcp=tcp!127.0.0.1!0'] if args.transport == 'tcp' else [] + with session(str(args.binary.resolve()), root, 'soak', *options, + tty=args.tty) as (control, unix_address): + pid = session_pid(control) + address = unix_address + if args.transport == 'tcp': + listeners = [line.split('!') for line in + control.read('/self/listeners').decode().splitlines() + if line.startswith('tcp!')] + assert len(listeners) == 1, listeners + transport, host, port = listeners[0] + assert transport == 'tcp' and host == '127.0.0.1' and 0 < int(port) < 65536, listeners + address = (host, int(port)) + client = Client(address) + panes = [] + try: + check_session(client, control, pid) + for slot in range(4): + body = payload(rng) + serial = 1 if slot == 0 else new_pane(client, body) + base = f'/self/pane/{serial}' + if slot == 0: + client.write(base + '/body', body, truncate=True) + path = root / f'pane-{slot}-0.zig' + client.write(base + '/ctl', f'name {path}\nput\n'.encode()) + panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0}) + emit(kind='start', seed=args.seed, session_pid=pid, + transport=args.transport, host='tty' if args.tty else 'detached', + batches=args.batches, duration_seconds=args.duration, + interval_seconds=args.interval, body_limit_bytes=BODY_LIMIT, + live_panes=len(panes), **memory(pid)) + if args.duration is not None: + deadline = time.monotonic() + args.duration + while (args.batches is None or batch < args.batches) and ( + deadline is None or time.monotonic() < deadline): + batch_started = time.monotonic() + operations = OPERATIONS * 2 + rng.shuffle(operations) + for operation in operations: + slot = rng.randrange(len(panes)) + pane = panes[slot] + base = f'/self/pane/{pane["serial"]}' + if operation == 'append': + addition = payload(rng, min(256, BODY_LIMIT - len(pane['body']))) + client.write(base + '/body', addition) + pane['body'] += addition + elif operation == 'range': + body = pane['body'] + # Keep range endpoints outside combining and ZWJ clusters. + boundaries = [0] + [i for i in range(1, len(body)) + if body[i - 1] < 128 and body[i] < 128] + [len(body)] + lo, hi = sorted((rng.choice(boundaries), rng.choice(boundaries))) + replacement = payload(rng, min(256, BODY_LIMIT - len(body) + hi - lo)) + if replacement: + client.write(base + '/addr', f'#{lo},#{hi}'.encode()) + client.write(base + '/data', replacement) + pane['body'] = body[:lo] + replacement + body[hi:] + elif operation == 'replace': + pane['body'] = payload(rng, BODY_LIMIT) + client.write(base + '/body', pane['body'], truncate=True) + elif operation == 'read': + check_body(client, pane, rng.choice([97, 251, 1024, 4096])) + elif operation == 'save': + pane['variant'] ^= 1 + pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig' + client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode()) + check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file') + check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount') + elif operation == 'reload': + pane['body'] = payload(rng) + pane['path'].write_bytes(pane['body']) + client.write(base + '/ctl', b'get\n') + elif operation == 'cycle': + old_serial = pane['serial'] + client.write(base + '/ctl', b'delete\n') + pane['body'] = payload(rng) + pane['serial'] = new_pane(client, pane['body']) + assert pane['serial'] != old_serial + base = f'/self/pane/{pane["serial"]}' + client.write(base + '/ctl', f'name {pane["path"]}\nput\n'.encode()) + elif operation == 'reconnect': + client.socket.close() + client = Client(address) + check_session(client, control, pid) + check_body(control, pane) + elif operation == 'screen': + frozen = client.open('/self/screen') + try: + before = read_fid(client, frozen, SCREEN_LIMIT, 4096) + check_screen(before) + addition = payload(rng, min(64, BODY_LIMIT - len(pane['body']))) + client.write(base + '/body', addition) + pane['body'] += addition + after = read_fid(client, frozen, SCREEN_LIMIT, rng.choice([251, 1024])) + check_bytes(after, before, 'frozen screen') + finally: + client.close(frozen) + fresh = client.open('/self/screen') + try: + check_screen(read_fid(client, fresh, SCREEN_LIMIT, 4096)) + finally: + client.close(fresh) + operation_count += 1 + check_body(client, pane) + expected = {str(pane['serial']) for pane in panes} + assert set(client.list('/self/pane')) == expected + assert set(control.list('/self/pane')) == expected + for pane in panes: + check_body(client, pane) + check_body(control, pane) + batch += 1 + emit(kind='batch', seed=args.seed, batch=batch, + duration_seconds=time.monotonic() - batch_started, + elapsed_seconds=time.monotonic() - started, + operations=len(operations), total_operations=operation_count, + model_bytes=sum(len(pane['body']) for pane in panes), **memory(pid)) + delay = args.interval - (time.monotonic() - batch_started) + if deadline is not None: + delay = min(delay, deadline - time.monotonic()) + if delay > 0: + time.sleep(delay) + finally: + client.socket.close() + emit(kind='summary', seed=args.seed, batches=batch, + transport=args.transport, host='tty' if args.tty else 'detached', + operations=operation_count, duration_seconds=time.monotonic() - started, + status='passed') + except BaseException as error: + emit(kind='failure', seed=args.seed, batch=batch + 1, operation=operation, + transport=args.transport, host='tty' if args.tty else 'detached', + operations=operation_count, session_pid=pid, + elapsed_seconds=time.monotonic() - started, error=str(error)) + raise + + +def main(): + parser = argparse.ArgumentParser(description='Deterministic valid-operation editor/9P soak; writes JSONL.') + parser.add_argument('binary', type=Path) + parser.add_argument('--seed', type=int, default=4200) + parser.add_argument('--transport', choices=['unix', 'tcp'], default='unix') + parser.add_argument('--tty', action='store_true', help='run an owned TTY host instead of a detached host') + limit = parser.add_mutually_exclusive_group() + limit.add_argument('--batches', type=int) + limit.add_argument('--duration', type=float, help='seconds, stopping after the current batch') + parser.add_argument('--interval', type=float, default=0, help='minimum seconds between batch starts') + args = parser.parse_args() + if args.batches is None and args.duration is None: + args.batches = 100 + if args.batches is not None and args.batches < 1: + parser.error('--batches must be positive') + if args.duration is not None and not 0 < args.duration < float('inf'): + parser.error('--duration must be finite and positive') + if not 0 <= args.interval < float('inf'): + parser.error('--interval must be finite and nonnegative') + run(args) + + +if __name__ == '__main__': + main() diff --git a/test/history.zig b/test/history.zig new file mode 100644 index 00000000..5ce45239 --- /dev/null +++ b/test/history.zig @@ -0,0 +1,502 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Dir = std.Io.Dir; +const schema_version = 2; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("history.zig"))}); +}; + +const Measurement = struct { + schema: u32 = 0, + harness: []const u8 = "", + zig_version: []const u8 = "", + revision: []const u8, + cwd: []const u8, + command: []const []const u8, + first_ns: u64, + median_ns: u64, + samples_ns: []const u64, + stdout: []const u8, + stderr: []const u8, + stable_stdout: bool, + stable_stderr: bool = false, + passed: bool, +}; + +const Compare = struct { + snapshots: bool = false, + benchmarks: bool = false, + max_ratio: ?f64 = null, +}; + +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const io = init.io; + const args = try init.minimal.args.toSlice(arena); + if (args.len < 2) return usage(io); + if (std.mem.eql(u8, args[1], "run")) { + const immutable = args.len > 2 and std.mem.eql(u8, args[2], "--allow-immutable"); + const pos: usize = if (immutable) 3 else 2; + if (args.len < pos + 4 or !std.mem.eql(u8, args[pos + 2], "--")) return usage(io); + try Dir.cwd().createDirPath(io, args[pos + 1]); + const output = try Dir.cwd().realPathFileAlloc(io, args[pos + 1], arena); + const exe = try std.process.executablePathAlloc(io, arena); + var command: std.ArrayList([]const u8) = .empty; + try command.appendSlice(arena, &.{ "jj", "--ignore-working-copy", "--no-integrate-operation" }); + if (immutable) try command.append(arena, "--ignore-immutable"); + try command.appendSlice(arena, &.{ "run", "--root", "-j", "1", "-r", args[pos], "--", exe, "record", output, "--" }); + try command.appendSlice(arena, args[pos + 3 ..]); + const result = try std.process.run(arena, io, .{ .argv = command.items }); + try std.Io.File.stdout().writeStreamingAll(io, result.stdout); + try std.Io.File.stderr().writeStreamingAll(io, result.stderr); + if (result.term != .exited or result.term.exited != 0) return error.HistoryRunFailed; + } else if (std.mem.eql(u8, args[1], "record")) { + if (args.len < 5 or !std.mem.eql(u8, args[3], "--")) return usage(io); + const command = args[4..]; + try Dir.cwd().createDirPath(io, args[2]); + const output = try Dir.cwd().realPathFileAlloc(io, args[2], arena); + const revision = init.environ_map.get("JJ_COMMIT_ID") orelse "working"; + const encoded_command = try std.json.Stringify.valueAlloc(arena, command, .{}); + const stem = try std.fmt.allocPrint(arena, "{s}/{s}-{x}", .{ + output, revision, std.hash.Wyhash.hash(0, encoded_command), + }); + try record(io, arena, stem, revision, command); + } else if (std.mem.eql(u8, args[1], "compare")) { + if (args.len < 4) return usage(io); + var options: Compare = .{}; + for (args[4..]) |arg| { + if (std.mem.eql(u8, arg, "--snapshots")) { + options.snapshots = true; + } else if (std.mem.eql(u8, arg, "--benchmarks")) { + options.benchmarks = true; + } else if (options.max_ratio == null) { + const ratio = std.fmt.parseFloat(f64, arg) catch return usage(io); + if (!std.math.isFinite(ratio) or ratio <= 0) return error.InvalidRatio; + options.max_ratio = ratio; + } else return usage(io); + } + if (options.snapshots and options.benchmarks) return usage(io); + var values: [2]Measurement = undefined; + for (&values, args[2..4]) |*value, path| { + const bytes = try Dir.cwd().readFileAlloc(io, path, arena, .limited(1024 * 1024)); + value.* = (try std.json.parseFromSlice(Measurement, arena, bytes, .{})).value; + } + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + const result = compare(io, arena, values, options, &report.writer); + try std.Io.File.stdout().writeStreamingAll(io, report.written()); + try result; + } else return usage(io); +} + +fn writeNew(io: std.Io, path: []const u8, bytes: []const u8) !void { + const file = try Dir.cwd().createFile(io, path, .{ .exclusive = true }); + defer file.close(io); + try file.writeStreamingAll(io, bytes); +} + +fn record(io: std.Io, arena: std.mem.Allocator, stem: []const u8, revision: []const u8, command: []const []const u8) !void { + const reservation = try std.fmt.allocPrint(arena, "{s}.reserved", .{stem}); + writeNew(io, reservation, "") catch |err| return if (err == error.PathAlreadyExists) error.MeasurementExists else err; + for ([_][]const u8{ ".json", ".0.stdout", ".0.stderr", ".1.stdout", ".1.stderr", ".2.stdout", ".2.stderr", ".3.stdout", ".3.stderr" }) |suffix| { + const path = try std.fmt.allocPrint(arena, "{s}{s}", .{ stem, suffix }); + _ = Dir.cwd().statFile(io, path, .{ .follow_symlinks = false }) catch |err| { + if (err == error.FileNotFound) continue; + return err; + }; + return error.MeasurementExists; + } + var times: [4]u64 = undefined; + var first_output: []const u8 = ""; + var first_error: []const u8 = ""; + var stable_stdout = true; + var stable_stderr = true; + var passed = true; + for (×, 0..) |*elapsed, index| { + const started = std.Io.Clock.awake.now(io); + const result = try std.process.run(arena, io, .{ + .argv = command, + .stdout_limit = .limited(64 * 1024 * 1024), + .stderr_limit = .limited(64 * 1024 * 1024), + }); + elapsed.* = @intCast(started.durationTo(std.Io.Clock.awake.now(io)).toNanoseconds()); + passed = passed and result.term == .exited and result.term.exited == 0; + if (index == 0) { + first_output = result.stdout; + first_error = result.stderr; + } else { + stable_stdout = stable_stdout and std.mem.eql(u8, first_output, result.stdout); + stable_stderr = stable_stderr and std.mem.eql(u8, first_error, result.stderr); + } + try writeNew(io, try std.fmt.allocPrint(arena, "{s}.{d}.stdout", .{ stem, index }), result.stdout); + try writeNew(io, try std.fmt.allocPrint(arena, "{s}.{d}.stderr", .{ stem, index }), result.stderr); + } + var warmed = times[1..].*; + std.mem.sort(u64, &warmed, {}, std.sort.asc(u64)); + const measurement: Measurement = .{ + .schema = schema_version, + .harness = harness_id, + .zig_version = builtin.zig_version_string, + .revision = revision, + .cwd = try std.process.currentPathAlloc(io, arena), + .command = command, + .first_ns = times[0], + .median_ns = warmed[1], + .samples_ns = ×, + .stdout = try std.fmt.allocPrint(arena, "{s}.0.stdout", .{stem}), + .stderr = try std.fmt.allocPrint(arena, "{s}.0.stderr", .{stem}), + .stable_stdout = stable_stdout, + .stable_stderr = stable_stderr, + .passed = passed, + }; + const json = try std.json.Stringify.valueAlloc(arena, measurement, .{ .whitespace = .indent_2 }); + const path = try std.fmt.allocPrint(arena, "{s}.json", .{stem}); + try writeNew(io, path, json); + const report = try std.fmt.allocPrint(arena, "{s}: first {d:.3}s, warm median {d:.3}s, passed={}\n", .{ + path, @as(f64, @floatFromInt(times[0])) / 1e9, @as(f64, @floatFromInt(warmed[1])) / 1e9, passed, + }); + try std.Io.File.stdout().writeStreamingAll(io, report); + if (!passed) return error.CommandFailed; +} + +fn compare(io: std.Io, arena: std.mem.Allocator, values: [2]Measurement, options: Compare, report: *std.Io.Writer) !void { + for (values) |value| if (value.schema != schema_version) return error.UnsupportedMeasurementVersion; + if (!std.mem.eql(u8, values[0].harness, values[1].harness) or + !std.mem.eql(u8, values[0].zig_version, values[1].zig_version)) return error.DifferentHarness; + if (!values[0].passed or !values[1].passed) return error.FailedMeasurement; + const a = try std.json.Stringify.valueAlloc(arena, values[0].command, .{}); + const b = try std.json.Stringify.valueAlloc(arena, values[1].command, .{}); + if (!std.mem.eql(u8, a, b)) return error.DifferentCommands; + const ratio = @as(f64, @floatFromInt(values[1].median_ns)) / @as(f64, @floatFromInt(@max(1, values[0].median_ns))); + const before = try Dir.cwd().readFileAlloc(io, values[0].stdout, arena, .limited(64 * 1024 * 1024)); + const after = try Dir.cwd().readFileAlloc(io, values[1].stdout, arena, .limited(64 * 1024 * 1024)); + const before_error = try Dir.cwd().readFileAlloc(io, values[0].stderr, arena, .limited(64 * 1024 * 1024)); + const after_error = try Dir.cwd().readFileAlloc(io, values[1].stderr, arena, .limited(64 * 1024 * 1024)); + const same_stdout = std.mem.eql(u8, before, after); + const same_stderr = std.mem.eql(u8, before_error, after_error); + const stable = values[0].stable_stdout and values[1].stable_stdout and values[0].stable_stderr and values[1].stable_stderr; + try report.print("warm runtime {d:.3}x; stdout {s}; stderr {s}; repeated captures stable={}\n", .{ + ratio, if (same_stdout) "identical" else "DIFFERS", if (same_stderr) "identical" else "DIFFERS", stable, + }); + if (options.snapshots and (!same_stdout or !same_stderr or !stable)) return error.SnapshotMismatch; + if (options.benchmarks) { + const before_runs = try readBenchmarkRuns(io, arena, values[0]); + const after_runs = try readBenchmarkRuns(io, arena, values[1]); + if (try compareBenchmarks(before_runs, after_runs, options.max_ratio, report)) return error.PerformanceRegression; + } + if (options.max_ratio) |limit| if (ratio > limit) return error.PerformanceRegression; +} + +const Benchmark = struct { + case: []const u8, + bytes: u64, + median_ns: u64, + cold_ns: u64, + allocations: u64, + allocated_bytes: u64, +}; + +fn readBenchmarks(arena: std.mem.Allocator, text: []const u8) ![]Benchmark { + var result: std.ArrayList(Benchmark) = .empty; + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + if (std.mem.trim(u8, line, " \t\r").len == 0) continue; + const value = (try std.json.parseFromSlice(Benchmark, arena, line, .{ .ignore_unknown_fields = true })).value; + if (value.case.len == 0) return error.EmptyBenchmarkName; + for (result.items) |old| if (std.mem.eql(u8, old.case, value.case)) return error.DuplicateBenchmark; + try result.append(arena, value); + } + if (result.items.len == 0) return error.NoBenchmarks; + return result.toOwnedSlice(arena); +} + +fn readBenchmarkRuns(io: std.Io, arena: std.mem.Allocator, value: Measurement) ![4][]Benchmark { + const suffix = ".0.stdout"; + if (value.samples_ns.len != 4 or !std.mem.endsWith(u8, value.stdout, suffix)) return error.InvalidBenchmarkCaptures; + const stem = value.stdout[0 .. value.stdout.len - suffix.len]; + var runs: [4][]Benchmark = undefined; + for (&runs, 0..) |*run, index| { + const path = try std.fmt.allocPrint(arena, "{s}.{d}.stdout", .{ stem, index }); + const bytes = try Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)); + run.* = try readBenchmarks(arena, bytes); + } + return runs; +} + +fn compareBenchmarks(before: [4][]Benchmark, after: [4][]Benchmark, max_ratio: ?f64, report: *std.Io.Writer) !bool { + const runs = [2][4][]Benchmark{ before, after }; + for (runs) |side| for (side) |run| { + if (run.len != before[0].len) return error.DifferentBenchmarks; + }; + var regressed = false; + for (before[0]) |first| { + var matched: [2][4]Benchmark = undefined; + for (runs, &matched) |side, *samples| for (side, samples) |run, *sample| { + sample.* = for (run) |value| { + if (std.mem.eql(u8, first.case, value.case)) break value; + } else return error.DifferentBenchmarks; + if (sample.bytes != first.bytes) return error.DifferentBenchmarkInputs; + }; + var medians = [2]Benchmark{ first, first }; + for (matched, &medians) |samples, *median| { + inline for (.{ "cold_ns", "median_ns", "allocations", "allocated_bytes" }) |field| { + var warm: [3]u64 = undefined; + for (samples[1..], &warm) |sample, *value| value.* = @field(sample, field); + std.mem.sort(u64, &warm, {}, std.sort.asc(u64)); + @field(median, field) = warm[1]; + } + } + const old = medians[0]; + const current = medians[1]; + const first_cold = @as(f64, @floatFromInt(matched[1][0].cold_ns)) / @as(f64, @floatFromInt(@max(1, matched[0][0].cold_ns))); + const cold = @as(f64, @floatFromInt(current.cold_ns)) / @as(f64, @floatFromInt(@max(1, old.cold_ns))); + const median = @as(f64, @floatFromInt(current.median_ns)) / @as(f64, @floatFromInt(@max(1, old.median_ns))); + const case_regression = if (max_ratio) |limit| cold > limit or median > limit else false; + regressed = regressed or case_regression; + try report.print("{s}: first cold {d:.3}x; cold {d:.3}x; median {d:.3}x; allocations {d}->{d}; bytes {d}->{d}{s}\n", .{ + old.case, + first_cold, + cold, + median, + old.allocations, + current.allocations, + old.allocated_bytes, + current.allocated_bytes, + if (case_regression) " REGRESSION" else "", + }); + } + return regressed; +} + +fn usage(io: std.Io) !void { + try std.Io.File.stderr().writeStreamingAll(io, "history run [--allow-immutable] REVSET OUT -- COMMAND...\n" ++ + "history record OUT -- COMMAND...\n" ++ + "history compare BEFORE.json AFTER.json [MAX_RATIO] [--snapshots|--benchmarks]\n" ++ + "run uses isolated jj workspaces without integrating history changes.\n" ++ + "Commands still have normal filesystem and network access.\n"); + return error.InvalidArguments; +} + +test "history output collisions preserve completed and partial recordings before command dispatch" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const gpa = arena.allocator(); + const io = std.testing.io; + for ([_][]const u8{ ".reserved", ".json", ".0.stdout", ".0.stderr", ".1.stdout", ".1.stderr", ".2.stdout", ".2.stderr", ".3.stdout", ".3.stderr" }) |suffix| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const output = try tmp.dir.realPathFileAlloc(io, ".", gpa); + const stem = try std.fmt.allocPrint(gpa, "{s}/measurement", .{output}); + const path = try std.fmt.allocPrint(gpa, "{s}{s}", .{ stem, suffix }); + try writeNew(io, path, "previous recording\n"); + const missing = try std.fmt.allocPrint(gpa, "{s}/must-not-be-dispatched", .{output}); + try std.testing.expectError(error.MeasurementExists, record(io, gpa, stem, "fixture", &.{missing})); + const preserved = try Dir.cwd().readFileAlloc(io, path, gpa, .limited(1024)); + try std.testing.expectEqualStrings("previous recording\n", preserved); + try std.testing.expectError(error.MeasurementExists, record(io, gpa, stem, "fixture", &.{missing})); + } +} + +const measurement_fixture: Measurement = .{ + .schema = schema_version, + .harness = harness_id, + .zig_version = builtin.zig_version_string, + .revision = "fixture", + .cwd = "", + .command = &.{ "fixture", "--argument" }, + .first_ns = 100, + .median_ns = 100, + .samples_ns = &.{ 100, 100, 100, 100 }, + .stdout = "", + .stderr = "", + .stable_stdout = true, + .stable_stderr = true, + .passed = true, +}; + +test "history rejects incomparable and failed measurements before reading captures" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + var values = [2]Measurement{ measurement_fixture, measurement_fixture }; + + values[1].schema = 0; + try std.testing.expectError(error.UnsupportedMeasurementVersion, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[0].schema = schema_version + 1; + try std.testing.expectError(error.UnsupportedMeasurementVersion, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[0] = measurement_fixture; + values[1].harness = "other harness"; + try std.testing.expectError(error.DifferentHarness, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].zig_version = "other compiler"; + try std.testing.expectError(error.DifferentHarness, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].command = &.{"fixture --argument"}; + try std.testing.expectError(error.DifferentCommands, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[1].passed = false; + try std.testing.expectError(error.FailedMeasurement, compare(std.testing.io, arena, values, .{}, &report.writer)); + values[1] = measurement_fixture; + values[0].passed = false; + try std.testing.expectError(error.FailedMeasurement, compare(std.testing.io, arena, values, .{}, &report.writer)); + try std.testing.expectEqualStrings("", report.written()); +} + +const benchmark_fixture = + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"last","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} +; + +test "history capture and performance gates reject false passes" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "before.{d}.stdout", .{run}); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = benchmark_fixture }); + } + try tmp.dir.writeFile(io, .{ .sub_path = "stderr", .data = "diagnostic\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "changed.0.stdout", .data = "changed\n" }); + var original = measurement_fixture; + original.stdout = try tmp.dir.realPathFileAlloc(io, "before.0.stdout", arena); + original.stderr = try tmp.dir.realPathFileAlloc(io, "stderr", arena); + const changed = try tmp.dir.realPathFileAlloc(io, "changed.0.stdout", arena); + var values = [2]Measurement{ original, original }; + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + + try compare(io, arena, values, .{ .snapshots = true }, &report.writer); + values[1].stdout = changed; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + values[1] = original; + values[1].stderr = changed; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + for (0..2) |index| { + values = .{ original, original }; + values[index].stable_stdout = false; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + values[index].stable_stdout = true; + values[index].stable_stderr = false; + try std.testing.expectError(error.SnapshotMismatch, compare(io, arena, values, .{ .snapshots = true }, &report.writer)); + try compare(io, arena, values, .{}, &report.writer); + } + values = .{ original, original }; + values[1].median_ns = 126; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .max_ratio = 1.25 }, &report.writer)); + values[1].median_ns = 125; + try compare(io, arena, values, .{ .max_ratio = 1.25 }, &report.writer); + + const slower_cold = + \\{"case":"last","bytes":20,"median_ns":50,"cold_ns":50,"allocations":5,"allocated_bytes":500} + \\{"case":"first","bytes":10,"median_ns":50,"cold_ns":126,"allocations":5,"allocated_bytes":500} + ; + const slower_warm = + \\{"case":"first","bytes":10,"median_ns":126,"cold_ns":50,"allocations":5,"allocated_bytes":500} + \\{"case":"last","bytes":20,"median_ns":50,"cold_ns":50,"allocations":5,"allocated_bytes":500} + ; + values[1].median_ns = 50; + values[1].stdout = changed; + for ([_][]const u8{ slower_cold, slower_warm }) |text| { + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "changed.{d}.stdout", .{run}); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = text }); + } + const begin = report.written().len; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); + const output = report.written()[begin..]; + try std.testing.expect(std.mem.indexOf(u8, output, "warm runtime 0.500x") != null); + try std.testing.expect(std.mem.indexOf(u8, output, " REGRESSION\nlast:") != null); + try compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.26 }, &report.writer); + try compare(io, arena, values, .{ .benchmarks = true }, &report.writer); + } + values[1] = original; + values[1].median_ns = 200; + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); +} + +test "history benchmark cases are nonempty unique and matched by name" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + try std.testing.expectError(error.NoBenchmarks, readBenchmarks(arena, " \n\t\n")); + try std.testing.expectError(error.DuplicateBenchmark, readBenchmarks(arena, benchmark_fixture ++ "\n" ++ benchmark_fixture)); + try std.testing.expectError(error.MissingField, readBenchmarks(arena, "{\"case\":\"first\"}")); + const before = try readBenchmarks(arena, benchmark_fixture); + const missing = try readBenchmarks(arena, "{\"case\":\"first\",\"bytes\":10,\"median_ns\":100,\"cold_ns\":100,\"allocations\":10,\"allocated_bytes\":1000}"); + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, .{missing} ** 4, null, &report.writer)); + const renamed = + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"renamed","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + ; + const different = try readBenchmarks(arena, renamed); + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, .{different} ** 4, null, &report.writer)); + const reordered = + \\{"case":"last","bytes":20,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + \\{"case":"first","bytes":10,"median_ns":100,"cold_ns":100,"allocations":10,"allocated_bytes":1000} + ; + const same = try readBenchmarks(arena, reordered); + try std.testing.expect(!try compareBenchmarks(.{before} ** 4, .{same} ** 4, 1, &report.writer)); + var samples = [4][]Benchmark{ before, before, before, different }; + try std.testing.expectError(error.DifferentBenchmarks, compareBenchmarks(.{before} ** 4, samples, null, &report.writer)); + samples[3] = try arena.dupe(Benchmark, before); + samples[3][0].bytes += 1; + try std.testing.expectError(error.DifferentBenchmarkInputs, compareBenchmarks(.{before} ** 4, samples, null, &report.writer)); +} + +test "history benchmark gate uses repeated process captures rather than the first process alone" { + var memory: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer memory.deinit(); + const arena = memory.allocator(); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var values = [2]Measurement{ measurement_fixture, measurement_fixture }; + const root = try tmp.dir.realPathFileAlloc(io, ".", arena); + for (&values, 0..) |*value, side| { + value.stdout = try std.fmt.allocPrint(arena, "{s}/{d}.0.stdout", .{ root, side }); + value.stderr = try std.fmt.allocPrint(arena, "{s}/{d}.0.stderr", .{ root, side }); + try writeNew(io, value.stderr, ""); + for (0..4) |run| { + const path = try std.fmt.allocPrint(arena, "{s}/{d}.{d}.stdout", .{ root, side, run }); + const duration: u64 = if (side == 1 and run != 0) 150 else 100; + const line = try std.fmt.allocPrint( + arena, + "{{\"case\":\"one\",\"bytes\":10,\"median_ns\":{d},\"cold_ns\":{d},\"allocations\":1,\"allocated_bytes\":10}}\n", + .{ duration, duration }, + ); + try writeNew(io, path, line); + } + } + var report: std.Io.Writer.Allocating = .init(arena); + defer report.deinit(); + try std.testing.expectError(error.PerformanceRegression, compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer)); + try std.testing.expect(std.mem.indexOf(u8, report.written(), "first cold 1.000x; cold 1.500x; median 1.500x") != null); + for ([_]u64{ 1000, 1000, 100, 100 }, 0..) |duration, run| { + const path = try std.fmt.allocPrint(arena, "1.{d}.stdout", .{run}); + const line = try std.fmt.allocPrint( + arena, + "{{\"case\":\"one\",\"bytes\":10,\"median_ns\":{d},\"cold_ns\":{d},\"allocations\":1,\"allocated_bytes\":10}}\n", + .{ duration, duration }, + ); + try tmp.dir.writeFile(io, .{ .sub_path = path, .data = line }); + } + const begin = report.written().len; + try compare(io, arena, values, .{ .benchmarks = true, .max_ratio = 1.25 }, &report.writer); + try std.testing.expect(std.mem.indexOf(u8, report.written()[begin..], "first cold 10.000x; cold 1.000x; median 1.000x") != null); + try tmp.dir.deleteFile(io, "1.3.stdout"); + try std.testing.expectError(error.FileNotFound, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); + values[1].samples_ns = &.{ 100, 100, 100 }; + try std.testing.expectError(error.InvalidBenchmarkCaptures, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); + values[1].samples_ns = measurement_fixture.samples_ns; + values[1].stdout = values[1].stderr; + try std.testing.expectError(error.InvalidBenchmarkCaptures, compare(io, arena, values, .{ .benchmarks = true }, &report.writer)); +} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 1a6fd269..39a6a7c9 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -1,13 +1,13 @@ -{"name":"ctrl-b-page","reason":"not a parity bug: Ctrl-b IS pardes's tty toggle (Options.tty_toggle = 'b'), so on a shell pane it enters raw tty mode instead of paging the view. Deliberate binding, same class as the alt-c-window-op waiver in the helix suite; Ctrl-u/Ctrl-d and PageUp cover half/full page scrolling on terminals."} -{"name":"ctrl-f-page","reason":"viewport geometry: a file pane's view scrolls anywhere inside its line count, a terminal's is the vt's and cannot move below the live grid bottom, so a full-page move clamps and the cursor snaps into a different scrolloff band. The edit model is not involved - no text differs, only where the view landed."} -{"name":"ctrl-f-small","reason":"viewport geometry: same as ctrl-f-page with a document shorter than the pane, where the terminal's view cannot scroll at all and the cursor snaps to the top scrolloff margin instead of the last line."} -{"name":"pgdn-basic","reason":"viewport geometry: PageDown is the same view scroll as Ctrl-f, and clamps at the terminal's live grid bottom. Text identical, view position differs."} -{"name":"z-pgdn-alias","reason":"viewport geometry: z-prefixed PageDown aliases the Ctrl-f view scroll, so it clamps at the terminal's live grid bottom exactly like pgdn-basic."} -{"name":"ins-pgdn","reason":"viewport geometry: insert-mode PageDown is bound to the same view scroll as normal mode, so it clamps at the terminal's live grid bottom. Text identical, view position differs."} -{"name":"zj-snaps-cursor","reason":"viewport geometry: zj scrolls the view one row and snaps the cursor back into the scrolloff band; on a terminal whose view is already at the vt's bottom the scroll is a no-op, so the cursor snaps from a different band."} -{"name":"zj-noop-in-band","reason":"viewport geometry: same as zj-snaps-cursor - the terminal's view cannot scroll past the live grid bottom, so the cursor's band differs by one row."} -{"name":"zk-bottom-snap","reason":"viewport geometry: zk scrolls the view up one row and snaps the cursor; the terminal's starting view offset differs because it could not scroll as far down in the first place."} -{"name":"unindent-tab","reason":"the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and never the tab the file pane unindents. Not an editing divergence - unindent-basic, unindent-partial, unindent-realign and unindent-cursor all match; only a byte a terminal cannot hold verbatim."} -{"name": "comment-tab-indent", "reason": "the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and the comment token goes in at a different column. Exactly the unindent-tab waiver's class - not an editing divergence; comment-indent, comment-uncomment-deep and the rest of the comment-* family match."} -{"name": "comment-noeol", "reason": "the case text is not representable in a pty pane: it is the one case in the corpus with NO trailing newline (that is the point - it pins helix's last-line handling), and the tty harness re-adds the newline ghostty's dump trims off the cursor row. The comment itself is identical on both sides; only the missing final byte differs."} -{"name": "comment-only-blank", "reason": "the case text is not representable in a pty pane: a literal TAB (unindent-tab's class) plus an all-whitespace last row, which the terminal dump trims off entirely. Both panes correctly change nothing - toggle_comments skips blank lines - so the only difference is what the pty could hold in the first place."} +{"name":"ctrl-b-page","reason":"Ctrl-b enters raw terminal mode instead of paging the view.","reference":{"name":"ctrl-b-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":18,"col":0},"anchor":{"row":18,"col":0}},"expected":{"name":"ctrl-b-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"tty","cursor":{"row":20,"col":0},"anchor":{"row":20,"col":0}}} +{"name":"ctrl-f-page","reason":"Terminal viewports cannot scroll below the live grid.","reference":{"name":"ctrl-f-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"ctrl-f-page","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"ctrl-f-small","reason":"Terminal viewports clamp short documents to the live grid.","reference":{"name":"ctrl-f-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}},"expected":{"name":"ctrl-f-small","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}}} +{"name":"pgdn-basic","reason":"Terminal page scrolling clamps to the live grid.","reference":{"name":"pgdn-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"pgdn-basic","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"z-pgdn-alias","reason":"Terminal page scrolling clamps to the live grid.","reference":{"name":"z-pgdn-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"z-pgdn-alias","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"ins-pgdn","reason":"Terminal insert-mode page scrolling clamps to the live grid.","reference":{"name":"ins-pgdn","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":25,"col":0},"anchor":{"row":25,"col":0}},"expected":{"name":"ins-pgdn","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"zj-snaps-cursor","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"zj-snaps-cursor","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},"expected":{"name":"zj-snaps-cursor","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":12,"col":0},"anchor":{"row":12,"col":0}}} +{"name":"zj-noop-in-band","reason":"Scrolling at the terminal grid bottom is a no-op.","reference":{"name":"zj-noop-in-band","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},"expected":{"name":"zj-noop-in-band","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}}} +{"name":"zk-bottom-snap","reason":"Terminal view offsets start at the live grid's scroll boundary.","reference":{"name":"zk-bottom-snap","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":19,"col":0},"anchor":{"row":19,"col":0}},"expected":{"name":"zk-bottom-snap","text":"l00\nl01\nl02\nl03\nl04\nl05\nl06\nl07\nl08\nl09\nl10\nl11\nl12\nl13\nl14\nl15\nl16\nl17\nl18\nl19\nl20\nl21\nl22\nl23\nl24\nl25\nl26\nl27\nl28\nl29\n","mode":"normal","cursor":{"row":20,"col":0},"anchor":{"row":20,"col":0}}} +{"name":"unindent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"unindent-tab","text":"x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"unindent-tab","text":" x\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"comment-tab-indent","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"comment-tab-indent","text":"\t# alpha\n\t# beta\n","mode":"normal","cursor":{"row":1,"col":7},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-tab-indent","text":" # alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":14},"anchor":{"row":0,"col":0}}} +{"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} +{"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} diff --git a/test/hxcases/regen.sh b/test/hxcases/regen.sh deleted file mode 100755 index 308cc9d2..00000000 --- a/test/hxcases/regen.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# Regenerate test/hxcases/goldens.jsonl by running the helix reference harness -# over cases.jsonl. The harness is hx-harness on the pardes-harness branch of -# the genizah helix checkout (see docs/helix-keys.md, "Differential testing"): -# cd ~/05-genizah/helix && git switch pardes-harness && \ -# cargo build --release -p helix-term --features helix-term/integration --bin hx-harness -# Override the binary with $HX_HARNESS. helix ignores pane:"tty" — a tty -# twin's golden is helix on the same text, which IS the oracle for tty -# navigation parity. -set -eu -cd "$(dirname "$0")" -HX_HARNESS="${HX_HARNESS:-$HOME/05-genizah/helix/target/release/hx-harness}" -[ -x "$HX_HARNESS" ] || { echo "regen.sh: no hx-harness at $HX_HARNESS (set \$HX_HARNESS)" >&2; exit 1; } -"$HX_HARNESS" cases.jsonl > goldens.jsonl.tmp -mv goldens.jsonl.tmp goldens.jsonl -wc -l goldens.jsonl diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index ca1b8ec5..82da2a08 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,6 +1,6 @@ -{"name":"wiX-edit-drops-sel","reason":"helix maps the selection through every insert-mode edit; pardes drops it on the first edit instead (anchor-only divergence, text+cursor+mode match). Re-judged phase 5 and kept: mapping the anchor through all ~23 mutation sites in handleInsert (multi-line inserts, EOL joins, kill runs) is genuinely invasive, and nothing in pardes consumes an implicit selection after insert-mode typing - the acme chords need explicit selections, which never enter insert."} -{"name":"alt-c-window-op","reason":"deliberate pardes binding: Alt-c is the move-pane-to-fresh-column window op in ANY mode (do-not-touch contract); helix Alt-c is change-noyank. Alt-d + i covers the helix behavior; the buffer is untouched on the pardes side."} -{"name":"msel-append","reason":"multiple cursors + `a`: pardes restores the appended-over span for the PRIMARY only, because Pane.append_at (where the append session began) is a single field and the other ranges have nowhere to keep their own origin. Every cursor lands on the right cell and the text matches; only the secondaries' anchors differ, so they read as bare cursors instead of spanning what was typed. Same family as wiX-edit-drops-sel: pardes does not map selections through insert-mode edits. Fix = an origin per SelRange."} -{"name":"msel-yank-paste","reason":"pardes has ONE yank register; helix's holds one VALUE PER RANGE and pastes value[i] back at range[i]. A multi-range `y` in pardes therefore joins the ranges' text with newlines and `p` puts the whole newline-joined blob at every cursor (which also makes it linewise). Deliberate: a per-range register is its own feature, not part of the selection model. Single-range yank/paste is unaffected and covered by the yank-* / paste-* cases."} -{"name": "sel-regex-dot-newline", "reason": "mvzr's `.` matches ANY byte, newlines included; helix builds its pattern with the Rust regex crate, where `.` excludes \\n unless asked. So `%s.` selects one range per byte in pardes and one per non-newline byte in helix. Deliberate: closing it means rewriting `.` to `[^\\n]` inside the pattern, and doing that correctly needs a real parse (`\\.` is a literal dot, a `.` inside `[...]` is already literal) - a regex parser written to work around the regex engine. `[^\\n]` typed at the prompt LOOKS like the workaround and is not one: the live preview compiles every prefix, and the prefix `[^\\` panics mvzr (index out of bounds in parseCharSet) before the pattern can be finished. Guarding the compile is what would make it typable."} -{"name": "sel-regex-caret", "reason": "helix compiles the `s`/`S` pattern with multi_line(true), so `^` and `$` are LINE anchors; mvzr has no such flag and asserts them against the START OF THE SLICE it is handed, which pardes advances to each match's end. `%s^a` therefore finds one match in pardes and one per line in helix. Same root cause for `$`. Fixing it means either searching line by line - which changes which ranges come out for every OTHER pattern, since a match may not span a line then - or an engine with multi-line assertions. Every other anchor-free pattern agrees, including \\b (sel-regex-boundary)."} +{"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}} +{"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}} +{"name":"msel-yank-paste","reason":"Pardes has one combined yank register, not one value per selection.","reference":{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-yank-paste","text":"aa\ncb\nca\ncd\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1}}],"primary":1}} +{"name":"sel-regex-dot-newline","reason":"The regex engine includes newlines in dot matches.","reference":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}],"primary":0},"expected":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}],"primary":0}} +{"name":"sel-regex-caret","reason":"The regex engine lacks Helix's multiline anchors.","reference":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0},"expected":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 94ee226e..22d8067d 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -1,62 +1,7 @@ -// pardes-hxdiff: the pardes half of the helix differential harness. Drives -// the core (src/pardes.zig) headlessly over JSON-Lines cases and -// prints one contract result line per case on stdout, input order — the same -// case/result contract helix's hx-harness speaks (pardes-harness branch in -// the genizah checkout), so the two editors diff key-for-key. -// -// case: {"name":"w-basic","pane":"file","text":"alpha beta\n","keys":"w"} -// result: {"name":"w-basic","text":"alpha beta\n","mode":"normal", -// "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} -// -// MULTIPLE CURSORS extend that line with two more fields, and only when there -// is more than one selection: -// -// ...,"sels":[{"cursor":…,"anchor":…},…],"primary":1 -// -// `sels` is EVERY range in document order, each end measured exactly like the -// `cursor`/`anchor` pair above, and `primary` is the index of the one those -// two fields describe. Omitted entirely at one selection — which is why every -// golden written before multiple cursors existed is still byte-for-byte valid. -// -// zig build hxdiff (diff the checked-in corpus) -// zig build hxdiff -- (results to stdout, no diff) -// zig build hxparity (file-vs-pty parity, both corpora) -// ./zig-out/bin/pardes-hxdiff [goldens.jsonl [waivers.jsonl]] -// ./zig-out/bin/pardes-hxdiff --parity [--waivers w.jsonl] ... -// -// DIFF MODE (goldens given): every result is compared field-by-field -// (text/mode/cursor/anchor) against the same-named golden line. Waivers -// ({"name":...,"reason":...}) exempt named cases — each must carry a reason. -// Any unwaivered mismatch prints a per-case report and exits 1. -// -// PARITY MODE (--parity): no goldens at all. Each case is run TWICE, once in -// a file pane and once in a pty pane, over the same starting text and the -// same keys, and the two result lines must be identical. That is the whole -// contract of "editing a shell pane behaves like editing a text file": the -// oracle is the file pane itself, so it cannot drift. The case's own "pane" -// field is ignored, and SEVERAL case files may be given — the gate runs the -// whole helix corpus, not just the editing cases, because a parity suite -// that only covers what its author remembered cannot catch the next -// regression. Divergences that are not editing (pardes key bindings, pty -// viewport geometry, text a terminal cannot hold verbatim) are waived by -// name in test/hxcases/parity-waivers.jsonl, each with its reason. -// -// Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body -// rows (topbar + tag take two; helix: statusline + commandline take two). -// pane "file" swaps the boot pane for a file pane holding the case text -// (Pardes.hxOpenFileContent, the test-only entry). pane "tty" keeps the -// tty_only boot shell — no real pty, the spawn effect is ignored — feeds the -// text as pty output with \n cooked to \r\n (ONLCR), then leaves tty mode -// with Ctrl- exactly like a live session and parks the cursor at -// (0,0), mirroring the helix harness's initial Selection::single(0,1). The -// final tty text is the motion surface (paneCursorLines) re-joined, plus the -// trailing '\n' ghostty's dump trims off the cursor's empty row — the case -// text always ends in one, so this restores byte-for-byte comparability. -// Keys are helix notation: printables verbatim, -// -// . +// JSONL cases and results shared with Helix's hx-harness. +// Default mode compares named goldens; --parity compares file and terminal panes. +// --strict checks complete corpus coverage. Waivers pin both sides of a known difference. const std = @import("std"); -const libc = std.c; const pardes = @import("pardes"); // Its own root, so src/main.zig's logFn does not apply here: the core it @@ -86,63 +31,92 @@ pub fn main(init: std.process.Init) !void { defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len == 2 and std.mem.eql(u8, args[1], "--self-test")) return testCli(init); var parity = false; + var verbose = false; + var strict = false; + var pane_override: ?[]const u8 = null; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; - const case_paths = arena.alloc([]const u8, args.len - 1) catch fatal("oom", .{}); - var n_case_paths: usize = 0; + const paths = try arena.alloc([]const u8, args.len - 1); + var n_paths: usize = 0; var want_waivers = false; for (args[1..]) |a| { if (want_waivers) { + if (std.mem.startsWith(u8, a, "--")) return error.MissingWaiverPath; waivers_path = a; want_waivers = false; } else if (std.mem.eql(u8, a, "--parity")) { parity = true; + } else if (std.mem.eql(u8, a, "--verbose")) { + verbose = true; + } else if (std.mem.eql(u8, a, "--strict")) { + strict = true; + } else if (std.mem.startsWith(u8, a, "--pane=")) { + const kind = a["--pane=".len..]; + if (!std.mem.eql(u8, kind, "file") and !std.mem.eql(u8, kind, "tty")) return error.UnknownPane; + pane_override = kind; } else if (std.mem.eql(u8, a, "--waivers")) { + if (waivers_path != null) return error.DuplicateWaiverPath; want_waivers = true; - } else if (parity or n_case_paths == 0) { - // diff mode is positional (cases, goldens, waivers); parity mode - // takes any number of case files and nothing else - case_paths[n_case_paths] = a; - n_case_paths += 1; - } else if (goldens_path == null) { - goldens_path = a; - } else if (waivers_path == null) { - waivers_path = a; - } else fatal("usage: pardes-hxdiff [goldens.jsonl [waivers.jsonl]]", .{}); + } else if (std.mem.startsWith(u8, a, "--")) { + return error.UnknownOption; + } else { + paths[n_paths] = a; + n_paths += 1; + } } - if (n_case_paths == 0) + if (n_paths == 0 or want_waivers) fatal("usage: pardes-hxdiff [--parity] ... [goldens.jsonl [waivers.jsonl]]", .{}); + const case_paths = paths[0..if (parity) n_paths else 1]; + if (!parity) { + if (n_paths > 3) return error.TooManyPaths; + if (n_paths >= 2) goldens_path = paths[1]; + if (n_paths == 3) { + if (waivers_path != null) return error.DuplicateWaiverPath; + waivers_path = paths[2]; + } + } + if (parity and pane_override != null) return error.ConflictingPaneModes; + if (!parity and goldens_path == null and waivers_path != null) return error.WaiversNeedComparison; // goldens + waivers, keyed by name (diff mode) var goldens: std.StringHashMap(Result) = .init(arena); if (goldens_path) |gp| { - const gsrc = readFileAlloc(arena, gp) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) }); + const gsrc = std.Io.Dir.cwd().readFileAlloc(init.io, gp, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ gp, @errorName(e) }); var glines = std.mem.splitScalar(u8, gsrc, '\n'); var lno: usize = 0; while (glines.next()) |raw| { lno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const g = std.json.parseFromSliceLeaky(Result, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad golden line: {s}", .{ gp, lno, @errorName(e) }); - goldens.put(g.name, g) catch fatal("oom", .{}); + try validateResult(g); + const entry = try goldens.getOrPut(g.name); + if (g.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate golden name {s}", .{ gp, lno, g.name }); + entry.value_ptr.* = g; } } - var waivers: std.StringHashMap([]const u8) = .init(arena); + var waivers: std.StringHashMap(Waiver) = .init(arena); if (waivers_path) |wp| { - const wsrc = readFileAlloc(arena, wp) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) }); + const wsrc = std.Io.Dir.cwd().readFileAlloc(init.io, wp, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ wp, @errorName(e) }); var wlines = std.mem.splitScalar(u8, wsrc, '\n'); var lno: usize = 0; while (wlines.next()) |raw| { lno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const w = std.json.parseFromSliceLeaky(Waiver, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad waiver line: {s}", .{ wp, lno, @errorName(e) }); if (w.reason.len == 0) fatal("{s}:{d}: waiver {s} MUST carry a reason", .{ wp, lno, w.name }); - waivers.put(w.name, w.reason) catch fatal("oom", .{}); + try validateResult(w.expected); + try validateResult(w.reference); + if (!std.mem.eql(u8, w.name, w.expected.name) or !std.mem.eql(u8, w.name, w.reference.name)) return error.WaiverNameMismatch; + const entry = try waivers.getOrPut(w.name); + if (w.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate waiver name {s}", .{ wp, lno, w.name }); + entry.value_ptr.* = w; } } @@ -150,51 +124,62 @@ pub fn main(init: std.process.Init) !void { var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; - for (case_paths[0..n_case_paths]) |path| { - const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); + var names: std.StringHashMap(void) = .init(arena); + for (case_paths) |path| { + const src = std.Io.Dir.cwd().readFileAlloc(init.io, path, arena, .limited(64 * 1024 * 1024)) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); var lines = std.mem.splitScalar(u8, src, '\n'); var lineno: usize = 0; while (lines.next()) |raw| { lineno += 1; const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = !strict }) catch |e| fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) }); + const entry = try names.getOrPut(case.name); + if (case.name.len == 0 or entry.found_existing) fatal("{s}:{d}: empty or duplicate case name {s}", .{ path, lineno, case.name }); n_cases += 1; - if (parity) { - // the file pane IS the oracle: same text, same keys, both kinds - const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) }); - const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) }); - const diff = diffResult(arena, want, got, "file", "pty"); - if (diff.len == 0) continue; - if (waivers.get(case.name)) |reason| { - n_waived += 1; - std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); - continue; - } - n_bad += 1; - std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); - continue; - } - const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); - if (goldens_path == null) { - // null sels/primary are DROPPED, not written: that is what - // keeps a one-selection line identical to the old contract - const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); - try stdout.writeStreamingAll(init.io, jl); + const reference: ?Result = if (parity) + try runCase(arena, case, "file") + else if (goldens_path != null) + goldens.get(case.name) orelse fatal("no golden for case {s}", .{case.name}) + else + null; + const actual = try runCase(arena, case, if (parity) "tty" else pane_override orelse case.pane); + try validateResult(actual); + const want = reference orelse { + const row = try std.json.Stringify.valueAlloc(arena, actual, .{ .emit_null_optional_fields = false }); + try stdout.writeStreamingAll(init.io, row); try stdout.writeStreamingAll(init.io, "\n"); continue; - } - const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name}); - const diff = diffResult(arena, g, res, "helix", "pardes"); - if (diff.len == 0) continue; - if (waivers.get(case.name)) |reason| { - n_waived += 1; - std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); + }; + try validateResult(want); + const reference_name = if (parity) "file" else "helix"; + const actual_name = if (parity) "pty" else "pardes"; + const difference = try diffResult(arena, want, actual, reference_name, actual_name); + if (difference.len == 0) { + if (strict and waivers.contains(case.name)) fatal("stale waiver: {s}", .{case.name}); continue; } + if (waivers.get(case.name)) |waiver| { + const reference_change = try diffResult(arena, waiver.reference, want, "waiver", reference_name); + const actual_change = try diffResult(arena, waiver.expected, actual, "waiver", actual_name); + if (reference_change.len == 0 and actual_change.len == 0) { + n_waived += 1; + if (verbose) std.debug.print("WAIVED {s}: {s}\n", .{ case.name, waiver.reason }); + continue; + } + std.debug.print("CHANGED WAIVER {s}\n{s}{s}", .{ case.name, reference_change, actual_change }); + } n_bad += 1; - std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); + std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, difference }); + } + } + if (n_cases == 0) return error.NoCases; + if (strict) { + if (goldens_path != null and n_cases != goldens.count()) return error.UnusedReferenceCases; + var entries = waivers.keyIterator(); + while (entries.next()) |name| { + if (!names.contains(name.*)) fatal("unused waiver: {s}", .{name.*}); } } if (!parity and goldens_path == null) return; @@ -229,91 +214,155 @@ const Result = struct { const Waiver = struct { name: []const u8, reason: []const u8, + reference: Result, + expected: Result, }; -/// field-by-field compare; returns a human-readable report ("" = match). -/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty). -fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 { - var parts: [5][]const u8 = undefined; - var n: usize = 0; - if (!std.mem.eql(u8, want.text, got.text)) { - parts[n] = std.fmt.allocPrint(arena, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch ""; - n += 1; - } - if (!std.mem.eql(u8, want.mode, got.mode)) { - parts[n] = std.fmt.allocPrint(arena, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch ""; - n += 1; - } - if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }) catch ""; - n += 1; +fn validateResult(result: Result) !void { + if (result.name.len == 0) return error.EmptyCaseName; + if (!std.mem.eql(u8, result.mode, "normal") and !std.mem.eql(u8, result.mode, "insert") and + !std.mem.eql(u8, result.mode, "select") and !std.mem.eql(u8, result.mode, "tty")) return error.UnknownMode; + try validatePosition(result.text, result.cursor); + try validatePosition(result.text, result.anchor); + const sels = result.sels orelse { + if (result.primary != null) return error.PrimaryWithoutSelections; + return; + }; + if (sels.len < 2) return error.NotMultipleSelections; + const primary = result.primary orelse return error.MissingPrimary; + if (primary >= sels.len) return error.InvalidPrimary; + if (!std.meta.eql(sels[primary].cursor, result.cursor) or + !std.meta.eql(sels[primary].anchor, result.anchor)) return error.PrimaryMismatch; + for (sels) |selection| { + try validatePosition(result.text, selection.cursor); + try validatePosition(result.text, selection.anchor); } - if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { - parts[n] = std.fmt.allocPrint(arena, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }) catch ""; - n += 1; +} + +fn validatePosition(text: []const u8, position: Pos) !void { + if (position.row < 0 or position.col < 0) return error.InvalidPosition; + var lines = std.mem.splitScalar(u8, text, '\n'); + var row: usize = 0; + while (lines.next()) |line| : (row += 1) { + if (row != @as(usize, @intCast(position.row))) continue; + if (@as(usize, @intCast(position.col)) > line.len) return error.InvalidPosition; + return; } - // the whole selection: absent on both sides = one range, and the two - // fields above already said everything about it - const ws = want.sels orelse &.{}; - const gs = got.sels orelse &.{}; - var sel_diff = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); - if (!sel_diff) for (ws, gs) |a, b| { - if (a.cursor.row != b.cursor.row or a.cursor.col != b.cursor.col or - a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; + return error.InvalidPosition; +} + +test "differential result positions include byte-length gaps and the empty EOF row" { + const cases = [_]struct { text: []const u8, position: Pos, valid: bool }{ + .{ .text = "", .position = .{ .row = 0, .col = 0 }, .valid = true }, + .{ .text = "", .position = .{ .row = 0, .col = 1 }, .valid = false }, + .{ .text = "abc", .position = .{ .row = 0, .col = 3 }, .valid = true }, + .{ .text = "abc", .position = .{ .row = 1, .col = 0 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 0, .col = 3 }, .valid = true }, + .{ .text = "abc\n", .position = .{ .row = 1, .col = 0 }, .valid = true }, + .{ .text = "abc\n", .position = .{ .row = 1, .col = 1 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 2, .col = 0 }, .valid = false }, + .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 9 }, .valid = true }, + .{ .text = "λ界😀\n", .position = .{ .row = 0, .col = 10 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = -1, .col = 0 }, .valid = false }, + .{ .text = "abc\n", .position = .{ .row = 0, .col = -1 }, .valid = false }, }; - if (sel_diff) { - parts[n] = std.fmt.allocPrint(arena, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }) catch ""; - n += 1; + for (cases) |case| { + const result: Result = .{ + .name = "position", + .text = case.text, + .mode = "normal", + .cursor = case.position, + .anchor = case.position, + }; + if (case.valid) try validateResult(result) else try std.testing.expectError(error.InvalidPosition, validateResult(result)); } - return concatParts(arena, parts[0..n]); } -/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to -/// one pair when they are the same cell), the primary starred -fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { - if (sels.len == 0) return ""; - var part_count: usize = 2; - for (sels, 0..) |s, i| { - part_count += 1; - if (i > 0) part_count += 1; - if (i == (primary orelse 0)) part_count += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) part_count += 1; - } - const parts = arena.alloc([]const u8, part_count) catch return ""; - var n: usize = 0; - parts[n] = std.fmt.allocPrint(arena, "{d} of [", .{sels.len}) catch ""; - n += 1; - for (sels, 0..) |s, i| { - if (i > 0) { - parts[n] = " "; - n += 1; - } - if (i == (primary orelse 0)) { - parts[n] = "*"; - n += 1; - } - parts[n] = std.fmt.allocPrint(arena, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }) catch ""; - n += 1; - if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) { - parts[n] = std.fmt.allocPrint(arena, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }) catch ""; - n += 1; +test "differential result positions validate secondary selections and anchors" { + const start: Pos = .{ .row = 0, .col = 0 }; + const end: Pos = .{ .row = 1, .col = 0 }; + var selections = [_]Sel{ + .{ .cursor = start, .anchor = start }, + .{ .cursor = end, .anchor = start }, + }; + var result: Result = .{ + .name = "multiple-positions", + .text = "λ界😀\n", + .mode = "normal", + .cursor = start, + .anchor = start, + .sels = &selections, + .primary = 0, + }; + try validateResult(result); + selections[1].cursor.col = 1; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); + selections[1].cursor = end; + selections[1].anchor.row = -1; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); + selections[1].anchor = start; + result.anchor.col = 10; + try std.testing.expectError(error.InvalidPosition, validateResult(result)); +} + +fn diffResult(allocator: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + if (!std.mem.eql(u8, want.text, got.text)) + out.writer.print(" text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }) catch return error.OutOfMemory; + if (!std.mem.eql(u8, want.mode, got.mode)) + out.writer.print(" mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }) catch return error.OutOfMemory; + if (!std.meta.eql(want.cursor, got.cursor)) + out.writer.print(" cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.cursor.row, want.cursor.col, gl, got.cursor.row, got.cursor.col }) catch return error.OutOfMemory; + if (!std.meta.eql(want.anchor, got.anchor)) + out.writer.print(" anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, want.anchor.row, want.anchor.col, gl, got.anchor.row, got.anchor.col }) catch return error.OutOfMemory; + const ws = want.sels orelse &.{}; + const gs = got.sels orelse &.{}; + var selections_differ = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); + if (!selections_differ) for (ws, gs) |a, b| { + if (!std.meta.eql(a, b)) { + selections_differ = true; + break; } - } - parts[n] = "]"; - n += 1; - return concatParts(arena, parts[0..n]); + }; + if (selections_differ) + out.writer.print(" sels: {s} {f} primary={d} != {s} {f} primary={d}\n", .{ wl, std.json.fmt(ws, .{}), want.primary orelse 0, gl, std.json.fmt(gs, .{}), got.primary orelse 0 }) catch return error.OutOfMemory; + return out.toOwnedSlice(); } -fn concatParts(arena: std.mem.Allocator, parts: []const []const u8) []const u8 { - var len: usize = 0; - for (parts) |part| len = std.math.add(usize, len, part.len) catch return ""; - const out = arena.alloc(u8, len) catch return ""; - var offset: usize = 0; - for (parts) |part| { - @memcpy(out[offset..][0..part.len], part); - offset += part.len; - } - return out; +test "differential reports compare values and report every changed field" { + const allocator = std.testing.allocator; + const want: Result = .{ + .name = "case", + .text = "one\n", + .mode = "normal", + .cursor = .{ .row = 0, .col = 0 }, + .anchor = .{ .row = 0, .col = 1 }, + }; + var got = want; + got.text = try allocator.dupe(u8, want.text); + defer allocator.free(got.text); + const same = try diffResult(allocator, want, got, "want", "got"); + defer allocator.free(same); + try std.testing.expectEqualStrings("", same); + + const changed: Result = .{ + .name = "case", + .text = "two\n", + .mode = "insert", + .cursor = .{ .row = 1, .col = 2 }, + .anchor = .{ .row = 1, .col = 0 }, + .sels = &.{.{ .cursor = .{ .row = 1, .col = 2 }, .anchor = .{ .row = 1, .col = 0 } }}, + }; + const Check = struct { + fn run(a: std.mem.Allocator, before: Result, after: Result) !void { + const difference = try diffResult(a, before, after, "want", "got"); + defer a.free(difference); + for ([_][]const u8{ "text:", "mode:", "cursor:", "anchor:", "sels:" }) |field| + try std.testing.expect(std.mem.indexOf(u8, difference, field) != null); + } + }; + try std.testing.checkAllAllocationFailures(allocator, Check.run, .{ want, changed }); } fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result { @@ -325,7 +374,7 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result pump(core); if (std.mem.eql(u8, pane_kind, "file")) { - _ = try core.hxOpenFileContent(case.text); + _ = try core.setTestFile(case.text); } else if (std.mem.eql(u8, pane_kind, "tty")) { // the harness is the pty here: cook \n to \r\n the way ONLCR would const cooked_len = std.math.add(usize, case.text.len, std.mem.count(u8, case.text, "\n")) catch return error.OutOfMemory; @@ -372,8 +421,8 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result name = name[2..]; } else break; } - if (name.len == 1) { - key.cp = name[0]; + if (name.len == 1 or (name.len > 1 and name.len <= 4 and name[0] >= 0x80)) { + key.cp = std.unicode.utf8Decode(name) catch return error.InvalidKey; if (!key.ctrl and !key.alt) key.text = name; } else if (std.mem.eql(u8, name, "esc")) { key.cp = pardes.Key.escape; @@ -417,9 +466,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result key.cp = pardes.Key.page_down; } else fatal("case {s}: unknown key <{s}>", .{ case.name, name }); } else { - key.cp = case.keys[ki]; - key.text = case.keys[ki .. ki + 1]; - ki += 1; + const len = std.unicode.utf8ByteSequenceLength(case.keys[ki]) catch return error.InvalidKey; + if (len > case.keys.len - ki) return error.InvalidKey; + const text = case.keys[ki..][0..len]; + key.cp = std.unicode.utf8Decode(text) catch return error.InvalidKey; + key.text = text; + ki += len; } core.update(.{ .key = key }); pump(core); @@ -436,12 +488,12 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // the cursor's empty row below the output (see Pardes.shellRows), so // its line list is exactly a file content's — join it and you have the // same bytes, trailing newline included, with nothing to patch up. - const pl = try core.paneCursorLines(pane); - var text_len: usize = if (pl.lines.len == 0) 0 else pl.lines.len - 1; - for (pl.lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; + const lines = try core.paneCursorLines(pane); + var text_len: usize = if (lines.len == 0) 0 else lines.len - 1; + for (lines) |ln| text_len = std.math.add(usize, text_len, ln.len) catch return error.OutOfMemory; const joined = try arena.alloc(u8, text_len); var offset: usize = 0; - for (pl.lines, 0..) |ln, i| { + for (lines, 0..) |ln, i| { if (i > 0) { joined[offset] = '\n'; offset += 1; @@ -506,38 +558,131 @@ fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } -fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 { - const path_z = try arena.dupeZ(u8, path); - const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return error.OpenFailed; - defer _ = libc.close(fd); - const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 or libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.StatFailed; - const size = std.math.cast(usize, end) orelse return error.FileTooLarge; - const buf = try arena.alloc(u8, size); - var offset: usize = 0; - while (offset < buf.len) { - const n = libc.read(fd, buf[offset..].ptr, buf.len - offset); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; - } - if (n == 0) return buf[0..offset]; - offset += @intCast(n); - } - var extra: [1]u8 = undefined; - while (true) { - const n = libc.read(fd, &extra, extra.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return error.ReadFailed; +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); + std.process.exit(1); +} + +fn testCli(init: std.process.Init) !void { + const io = init.io; + const arena = init.arena.allocator(); + const exe = try std.process.executablePathAlloc(io, arena); + var random: [12]u8 = undefined; + io.random(&random); + const path = try std.fmt.allocPrint(arena, "hxdiff-{x}", .{random}); + var parent = try std.Io.Dir.cwd().createDirPathOpen(io, ".zig-cache/tmp", .{}); + defer parent.close(io); + try parent.createDir(io, path, .default_dir); + defer parent.deleteTree(io, path) catch {}; + var dir = try parent.openDir(io, path, .{}); + defer dir.close(io); + + const case_a = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\"}\n"; + const case_b = "{\"name\":\"b\",\"text\":\"b\\n\",\"keys\":\"\"}\n"; + const result_tail = ",\"mode\":\"normal\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}"; + const result_a = "{\"name\":\"a\",\"text\":\"a\\n\"" ++ result_tail; + const result_b = "{\"name\":\"b\",\"text\":\"b\\n\"" ++ result_tail; + const different = "{\"name\":\"a\",\"text\":\"different\\n\"" ++ result_tail; + const changed = "{\"name\":\"a\",\"text\":\"changed\\n\"" ++ result_tail; + const waiver = "{\"name\":\"a\",\"reason\":\"fixture\",\"reference\":" ++ different ++ ",\"expected\":" ++ result_a ++ "}\n"; + const missing_waiver = "{\"name\":\"b\",\"reason\":\"fixture\",\"reference\":" ++ result_b ++ ",\"expected\":" ++ result_b ++ "}\n"; + const Fixture = struct { + name: []const u8, + cases: []const u8 = case_a, + reference: []const u8 = result_a, + waivers: []const u8 = "", + args: []const []const u8 = &.{ "--strict", "cases", "reference", "waivers" }, + message: []const u8 = "", + }; + const fixtures = [_]Fixture{ + .{ .name = "equal" }, + .{ .name = "subset", .reference = result_a ++ "\n" ++ result_b, .args = &.{ "cases", "reference" } }, + .{ .name = "missing reference", .cases = case_a ++ case_b, .message = "no golden for case b" }, + .{ .name = "unused reference", .reference = result_a ++ "\n" ++ result_b, .message = "UnusedReferenceCases" }, + .{ .name = "empty corpus", .cases = "\n", .reference = "", .message = "NoCases" }, + .{ .name = "duplicate cases", .cases = case_a ++ case_a, .message = "duplicate case name" }, + .{ .name = "duplicate references", .reference = result_a ++ "\n" ++ result_a, .message = "duplicate golden name" }, + .{ .name = "empty name", .cases = "{\"name\":\"\",\"text\":\"a\",\"keys\":\"\"}\n", .message = "empty or duplicate case name" }, + .{ .name = "mismatch", .reference = different, .message = "MISMATCH a" }, + .{ .name = "pinned waiver", .reference = different, .waivers = waiver }, + .{ .name = "changed actual", .cases = "{\"name\":\"a\",\"text\":\"changed\\n\",\"keys\":\"\"}\n", .reference = different, .waivers = waiver, .message = "CHANGED WAIVER a" }, + .{ .name = "changed reference", .reference = changed, .waivers = waiver, .message = "CHANGED WAIVER a" }, + .{ .name = "stale waiver", .waivers = waiver, .message = "stale waiver: a" }, + .{ .name = "unused waiver", .waivers = missing_waiver, .message = "unused waiver: b" }, + .{ .name = "duplicate waivers", .reference = different, .waivers = waiver ++ waiver, .message = "duplicate waiver name" }, + .{ .name = "unknown case field", .cases = "{\"name\":\"a\",\"text\":\"a\\n\",\"keys\":\"\",\"typo\":true}\n", .message = "UnknownField" }, + .{ .name = "unknown reference field", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"typo\":true" ++ result_tail, .message = "UnknownField" }, + .{ .name = "unknown mode", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"mode\":\"unknown\",\"cursor\":{\"row\":0,\"col\":0},\"anchor\":{\"row\":0,\"col\":0}}\n", .message = "UnknownMode" }, + .{ .name = "primary without selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"primary\":0" ++ result_tail, .message = "PrimaryWithoutSelections" }, + .{ .name = "empty selections", .reference = "{\"name\":\"a\",\"text\":\"a\\n\",\"sels\":[]" ++ result_tail, .message = "NotMultipleSelections" }, + .{ .name = "missing waiver argument", .args = &.{ "cases", "--waivers" }, .message = "usage:" }, + .{ .name = "flag instead of waiver path", .args = &.{ "cases", "--waivers", "--strict" }, .message = "MissingWaiverPath" }, + .{ .name = "duplicate waiver option", .args = &.{ "cases", "reference", "--waivers", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, + .{ .name = "positional and named waiver", .args = &.{ "cases", "reference", "waivers", "--waivers", "waivers" }, .message = "DuplicateWaiverPath" }, + .{ .name = "unknown option", .args = &.{ "cases", "--typo" }, .message = "UnknownOption" }, + .{ .name = "too many paths", .args = &.{ "cases", "reference", "waivers", "extra" }, .message = "TooManyPaths" }, + .{ .name = "waivers without comparison", .args = &.{ "cases", "--waivers", "waivers" }, .message = "WaiversNeedComparison" }, + .{ .name = "conflicting pane modes", .args = &.{ "--parity", "--pane=file", "cases" }, .message = "ConflictingPaneModes" }, + .{ .name = "unknown pane", .args = &.{ "--pane=unknown", "cases" }, .message = "UnknownPane" }, + .{ .name = "parity", .args = &.{ "--parity", "--strict", "cases" } }, + .{ .name = "parity option after path", .args = &.{ "cases", "--strict", "--parity" } }, + .{ .name = "emit", .args = &.{"cases"} }, + }; + for (fixtures) |fixture| { + try dir.writeFile(io, .{ .sub_path = "cases", .data = fixture.cases }); + try dir.writeFile(io, .{ .sub_path = "reference", .data = fixture.reference }); + try dir.writeFile(io, .{ .sub_path = "waivers", .data = fixture.waivers }); + const argv = try arena.alloc([]const u8, fixture.args.len + 1); + argv[0] = exe; + @memcpy(argv[1..], fixture.args); + const result = try std.process.run(arena, io, .{ + .argv = argv, + .cwd = .{ .dir = dir }, + .stdout_limit = .limited(1024 * 1024), + .stderr_limit = .limited(1024 * 1024), + .timeout = .{ .duration = .{ .clock = .awake, .raw = .fromSeconds(5) } }, + }); + const passed = result.term == .exited and result.term.exited == 0; + if (result.term != .exited or passed != (fixture.message.len == 0) or + std.mem.indexOf(u8, result.stderr, fixture.message) == null) + { + std.debug.print("CLI fixture {s}: {s}\n{s}\n", .{ fixture.name, result.stdout, result.stderr }); + return error.DifferentialDriverTestFailed; } - if (n > 0) return error.FileChanged; - return buf; + if (std.mem.eql(u8, fixture.name, "emit") and !std.mem.eql(u8, result.stdout, result_a ++ "\n")) return error.WrongEmittedResult; } + std.debug.print("hxdiff: {d} CLI regression fixtures passed\n", .{fixtures.len}); } -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-hxdiff: " ++ fmt ++ "\n", args); - std.process.exit(1); +test "differential cases deliver each Unicode scalar as one key event" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const finds = [_]struct { keys: []const u8, col: i32 }{ + .{ .keys = "fλ", .col = 4 }, + .{ .keys = "f<λ>", .col = 4 }, + .{ .keys = "f界", .col = 7 }, + .{ .keys = "f<界>", .col = 7 }, + .{ .keys = "f😀", .col = 11 }, + .{ .keys = "f<😀>", .col = 11 }, + }; + for (finds) |case| { + const result = try runCase(arena, .{ .name = "unicode-find", .text = "one λ 界 😀 two\n", .keys = case.keys }, "file"); + try std.testing.expectEqualStrings("one λ 界 😀 two\n", result.text); + try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); + } + const inserts = [_]struct { keys: []const u8, mode: []const u8, col: i32 }{ + .{ .keys = "iλ界😀", .mode = "insert", .col = "λ界😀".len }, + .{ .keys = "i<λ><界><😀>", .mode = "insert", .col = "λ界😀".len }, + .{ .keys = "iλ界😀", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "i<λ><界><😀>", .mode = "normal", .col = "λ界😀".len }, + .{ .keys = "iλ界😀h", .mode = "normal", .col = "λ界".len }, + .{ .keys = "i<λ><界><😀>h", .mode = "normal", .col = "λ界".len }, + }; + for (inserts) |case| { + const result = try runCase(arena, .{ .name = "unicode-insert", .text = "\n", .keys = case.keys }, "file"); + try std.testing.expectEqualStrings("λ界😀\n", result.text); + try std.testing.expectEqualStrings(case.mode, result.mode); + try std.testing.expectEqual(Pos{ .row = 0, .col = case.col }, result.cursor); + } } diff --git a/test/lspbench.zig b/test/lspbench.zig index 964d3c8c..59cac155 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -1,33 +1,9 @@ -//! The language-backend scoreboard: one binary, three numbers. -//! -//! zig build lspbench -- latency + feature matrix over pardes's own src/ -//! zig build lspbench -- --json -- the same, machine-readable -//! -//! Every competing backend links the same harness against the same corpus and -//! the same query list, so the columns mean the same thing across all of them. -//! What it measures, in the order the evaluation weighs it: -//! -//! FEATURES which lsp.Kind values actually return rows. Claiming support in -//! `lsp.supports` and returning nothing is a FALSE claim and shows -//! up as `claimed-empty` — the harness trusts results, not flags. -//! LATENCY cold (first query, index build included) and warm (median of N) -//! per kind. Cold is what a keypress costs the first time; warm is -//! what it costs forever after. Both matter and they differ by -//! orders of magnitude for an indexing backend. -//! MEMORY peak RSS delta across the run, read from /proc. -//! -//! Lines of code is not measured here — it is `jj diff --stat` against the -//! base commit, which is the honest number (a backend that vendors a library -//! pays for what it vendors only in build time, not in code we maintain). const std = @import("std"); const libc = std.c; const lsp = @import("pardes").lsp; -// 0.16 slimmed std.fs (no cwd(), no realpathAlloc) and this repo goes through -// libc everywhere for exactly that reason — see look.zig. extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; -/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig already uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -36,53 +12,25 @@ fn nowNs() u64 { pub const std_options: std.Options = .{ .log_level = .err }; -/// One probe. The corpus is pardes's own source: real Zig, in this repo, that -/// every implementation can reach without a fixture tree to keep in sync. -/// -/// The cursor is pinned by SYMBOL, not by line — the needle is searched for at -/// startup — so editing pardes.zig cannot silently rot the bench into probing -/// a blank line and calling the result "no support". -/// -/// `expect` is a substring the rows must contain for the probe to count as -/// CORRECT rather than merely non-empty. A backend that returns a plausible -/// wrong location scores worse than one that returns nothing, and only this -/// field can tell those two apart. +// Coordinates are byte offsets within the first needle occurrence, not source line numbers. const Anchor = struct { file: []const u8, - /// the exact source text to put the cursor on; the FIRST occurrence wins needle: []const u8, - /// byte offset into `needle` where the cursor sits at: u32 = 0, kind: lsp.Kind, expect: []const u8 = "", }; const anchors = [_]Anchor{ - // a call to a function defined in the same file - .{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, - // a plain local/decl reference within one file + .{ .file = "src/pardes.zig", .needle = "modal.cursorLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" }, - // a std reference: needs the zig lib dir, which is the first thing a - // single-file backend cannot do .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" }, - // hover over the same symbol .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" }, - // the file's own symbols .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" }, .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" }, - // references to a symbol used in several places .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" }, - // Diagnostics and format probe a DELIBERATELY BROKEN fixture, never the - // real source. On a clean corpus the correct answer is nothing, which is - // byte-identical to "this backend has no diagnostics" — a blind spot that - // rewards emitting a filler row and punishes honesty. test/lspfixture has - // an unused local (semantic: needs a compiler front end, not a tokenizer) - // and a misformatted fn (needs a formatter), so both probes have real work. .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .diagnostics, .expect = "broken.zig" }, - // the remaining kinds, probed once each so the matrix is complete .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, - // cursor on `out`, whose type is `*std.Io.Writer` — a type_definition probe - // wants a NAME whose type must be resolved, not the type spelled out .{ .file = "src/lsp/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, @@ -91,131 +39,179 @@ const anchors = [_]Anchor{ .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, - // The three shapes a `.` can have, each with the cursor IMMEDIATELY AFTER - // the dot, which is where Tab asks from. - // a field access into std: `std.` lists what `std` declares, the same - // "can this backend reach the stdlib at all" test `gd` gets. .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 4, .kind = .completion, .expect = "std.zig" }, - // an enum literal in a FINISHED switch arm. The tree parses, so this - // probes the expected-type resolution and nothing else. .{ .file = "src/lsp/lsp_zls.zig", .needle = ".declaration => try rowForToken", .at = 1, .kind = .completion, .expect = "type_definition" }, - // the same thing on the BIGGEST file in the tree. This one is here for - // its latency column rather than its rows: a completion parses the - // buffer once per placeholder spelling it tries, so pardes.zig is where - // that shows up and where the figure quoted in docs/lsp.md comes from. .{ .file = "src/pardes.zig", .needle = ".definition => .definition,", .at = 1, .kind = .completion, .expect = "" }, - // an enum literal in a HALF-TYPED switch arm, which is what a real - // keypress looks like: the file does not parse and the switch is not in - // the tree at all. `verdigris` appears only on the enum member's own - // line, so a row carrying it came from the DEFINITION and not from the - // line the cursor sits on — and it has to appear TWICE, because a - // completion row carries the candidate's own name (the word that would go - // after the dot) before the declaration line it was read off. + // The repeated name proves the result includes the declaration, not just the cursor line. .{ .file = "test/lspfixture/dotcomplete.zig", .needle = "return switch (s) {\n .", .at = 29, .kind = .completion, .expect = "verdigris verdigris," }, - // ...and the same dot on a line that is ALSO missing its terminator, - // which is one parse error repaired or not. This is the probe that - // catches a repair chosen by error count rather than by whether the dot - // became reachable in the tree. - // (the needle carries its indent: dothalf.zig's own header explains the - // shape and would otherwise be the first match) + // Indentation keeps the fixture header from becoming the first needle match. .{ .file = "test/lspfixture/dothalf.zig", .needle = " const z: Shade = .", .at = 22, .kind = .completion, .expect = "verdigris" }, }; const Result = struct { kind: lsp.Kind, - /// no rows came back empty: bool = true, - /// rows came back AND contained `expect` (or nothing was expected) correct: bool = false, rows: usize = 0, cold_us: u64 = 0, warm_us: u64 = 0, + + fn accept(r: *Result, output: []const u8, expected: []const u8) void { + r.empty = output.len == 0; + r.rows = std.mem.count(u8, output, "\n"); + r.correct = !r.empty and (expected.len == 0 or std.mem.indexOf(u8, output, expected) != null); + } }; const warm_iters = 20; +const Options = struct { + json: bool = false, + check: bool = false, + root: []const u8 = ".", + + fn parse(args: []const []const u8) !Options { + var options: Options = .{}; + var has_root = false; + for (args) |arg| { + if (std.mem.eql(u8, arg, "--json")) { + options.json = true; + } else if (std.mem.eql(u8, arg, "--check")) { + options.check = true; + } else if (std.mem.startsWith(u8, arg, "-")) { + return error.UnknownArgument; + } else { + if (has_root) return error.MultipleRoots; + options.root = arg; + has_root = true; + } + } + return options; + } +}; + +test "lspbench options reject unknown flags and multiple roots before probing" { + const defaults = try Options.parse(&.{}); + try std.testing.expect(!defaults.json and !defaults.check); + try std.testing.expectEqualStrings(".", defaults.root); + for ([_][]const []const u8{ + &.{ "--json", "repo root", "--check" }, + &.{ "repo root", "--check", "--json" }, + }) |args| { + const options = try Options.parse(args); + try std.testing.expect(options.json and options.check); + try std.testing.expectEqualStrings("repo root", options.root); + } + for ([_][]const []const u8{ + &.{ "--reps", "3" }, + &.{ "--json", "--unknown" }, + &.{ "repo", "--check=true" }, + }) |args| try std.testing.expectError(error.UnknownArgument, Options.parse(args)); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "second" })); + try std.testing.expectError(error.MultipleRoots, Options.parse(&.{ "first", "--check", "second" })); +} + pub fn main(init: std.process.Init) !void { - // page_allocator like the other harnesses: this measures the BACKEND, and - // a debug allocator's bookkeeping would land in every number. const gpa = std.heap.page_allocator; const args = try init.minimal.args.toSlice(init.arena.allocator()); - var json = false; - var root: []const u8 = "."; - for (args[1..]) |a| { - if (std.mem.eql(u8, a, "--json")) json = true else root = a; - } + const options = try Options.parse(args[1..]); const rss0 = rssKib(); var results: [anchors.len]Result = undefined; var result_count: usize = 0; for (anchors) |an| { - const path = try std.fs.path.join(gpa, &.{ root, an.file }); - defer gpa.free(path); - const src = readZ(gpa, path) catch continue; - defer gpa.free(src); - const off = std.mem.indexOf(u8, src, an.needle) orelse { - std.debug.print("bench: anchor not found in {s}: `{s}`\n", .{ an.file, an.needle }); + results[result_count] = probe(gpa, options.root, an) catch |err| { + std.debug.print("bench: {s} {s} `{s}`: {s}\n", .{ an.file, @tagName(an.kind), an.needle, @errorName(err) }); continue; }; - var realbuf: [4096]u8 = undefined; - var pz: [4096:0]u8 = undefined; - const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0) catch continue; - const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse continue); - const dir = std.fs.path.dirname(abs) orelse "/"; - const req: lsp.Req = .{ - .kind = an.kind, - .path = abs, - .source = src, - .offset = @intCast(off + an.at), - .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", - .root = dir, - }; - - var r: Result = .{ .kind = an.kind }; - // COLD: whatever the backend has to build the first time counts. - r.cold_us = runOnce(gpa, req, &r); - // WARM: median of warm_iters, so one scheduling hiccup cannot flatter - // or damn a backend. - var samples: [warm_iters]u64 = undefined; - for (&samples) |*s| { - var throwaway: Result = .{ .kind = an.kind }; - s.* = runOnce(gpa, req, &throwaway); - } - std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); - r.warm_us = samples[warm_iters / 2]; - r.correct = !r.empty and (an.expect.len == 0 or blk: { - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - lsp.query(gpa, arena.allocator(), req, &out.writer); - break :blk std.mem.indexOf(u8, out.written(), an.expect) != null; - }); - results[result_count] = r; result_count += 1; } const rss = rssKib() -| rss0; - if (json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.json) reportJson(results[0..result_count], rss) else reportText(results[0..result_count], rss); + if (options.check) try checkResults(results[0..result_count], anchors.len); +} + +fn probe(gpa: std.mem.Allocator, root: []const u8, an: Anchor) !Result { + const path = try std.fs.path.join(gpa, &.{ root, an.file }); + defer gpa.free(path); + const src = try readZ(gpa, path); + defer gpa.free(src); + const off = std.mem.indexOf(u8, src, an.needle) orelse return error.MissingAnchor; + if (an.at > an.needle.len) return error.InvalidAnchor; + var realbuf: [4096]u8 = undefined; + var pz: [4096:0]u8 = undefined; + const pz_s = try std.fmt.bufPrintSentinel(&pz, "{s}", .{path}, 0); + const abs = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse return error.RealPathFailed); + const req: lsp.Req = .{ + .kind = an.kind, + .path = abs, + .source = src, + .offset = @intCast(off + an.at), + .arg = if (an.kind == .rename) "renamed_by_bench" else if (an.kind == .workspace_symbols) "Kind" else "", + .root = std.fs.path.dirname(abs) orelse "/", + }; + var r: Result = .{ .kind = an.kind }; + r.cold_us = try runOnce(gpa, req, an.expect, &r); + var samples: [warm_iters]u64 = undefined; + for (&samples) |*sample| { + var warm: Result = .{ .kind = an.kind }; + sample.* = try runOnce(gpa, req, an.expect, &warm); + r.correct = r.correct and warm.correct; + } + std.mem.sort(u64, &samples, {}, std.sort.asc(u64)); + r.warm_us = samples[warm_iters / 2]; + return r; +} + +fn checkResults(results: []const Result, required: usize) !void { + if (results.len != required) return error.MissingProbes; + for (results) |r| if (r.empty or !r.correct) return error.IncorrectProbe; } -fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, r: *Result) u64 { +fn runOnce(gpa: std.mem.Allocator, req: lsp.Req, expected: []const u8, r: *Result) !u64 { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const t0 = nowNs(); - lsp.query(gpa, arena.allocator(), req, &out.writer); + try lsp.query(gpa, arena.allocator(), req, &out.writer); const ns = nowNs() -| t0; - if (out.written().len > 0) { - r.empty = false; - r.rows = std.mem.count(u8, out.written(), "\n"); - } + r.accept(out.written(), expected); return ns / 1000; } +test "lspbench check rejects omitted empty and incorrect probes" { + var result: Result = .{ .kind = .definition }; + try std.testing.expectError(error.MissingProbes, checkResults(&.{}, 1)); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "expected.zig"); + try std.testing.expect(!result.empty); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); + result.accept("actual.zig:1:1: symbol\n", "actual.zig"); + try checkResults(&.{result}, 1); + try std.testing.expectError(error.MissingProbes, checkResults(&.{result}, 2)); + result.accept("", ""); + try std.testing.expectError(error.IncorrectProbe, checkResults(&.{result}, 1)); +} + +test "lspbench reports missing fixture and anchor before querying a backend" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var path_buf: [4096]u8 = undefined; + const root = path_buf[0..try tmp.dir.realPath(std.testing.io, &path_buf)]; + const anchor: Anchor = .{ .file = "source.zig", .needle = "pub const value", .kind = .definition }; + try std.testing.expectError(error.OpenFailed, probe(std.testing.allocator, root, anchor)); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = anchor.file, .data = "const value = 1;\n" }); + try std.testing.expectError(error.MissingAnchor, probe(std.testing.allocator, root, anchor)); + var invalid = anchor; + invalid.needle = "value"; + invalid.at = 6; + try std.testing.expectError(error.InvalidAnchor, probe(std.testing.allocator, root, invalid)); +} + fn reportText(rs: []const Result, rss: u64) void { const o = std.debug.print; o("backend: {s}\n\n", .{lsp.backend_name}); @@ -284,8 +280,6 @@ fn readZ(gpa: std.mem.Allocator, path: []const u8) ![:0]u8 { return buf[0..len :0]; } -/// VmHWM from /proc/self/status — the peak, not the current, so a backend that -/// frees its index before returning still pays for having built it. fn rssKib() u64 { const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); if (fd < 0) return 0; diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift index 800e2d43..49e0a7f2 100644 --- a/test/macos_e2e.swift +++ b/test/macos_e2e.swift @@ -386,21 +386,7 @@ private final class Driver: PardesViewDelegate { private func snap(_ label: String) { let frame = readFrame() output += "== snap \(label) grid=\(frame.cols)x\(frame.rows) cursor=\(frame.cursorX),\(frame.cursorY)\n" - for line in frame.lines { output += "|\(stableNames(line))\n" } - } - - /// `New` asks the shell for a temporary document and mkstemp picks six - /// random characters for it (src/temp_file.zig), so the pane tag holding - /// that name is different on every run. TMPDIR is already pinned inside the - /// hermetic world, which makes the DIRECTORY reproducible; this makes the - /// name reproducible. Masked rather than dropped, so a golden still shows - /// that a temp document is what got opened and where. - private func stableNames(_ line: String) -> String { - guard line.contains("pardes-") else { return line } - return line.replacingOccurrences( - of: "pardes-[A-Za-z0-9]{6}", - with: "pardes-XXXXXX", - options: .regularExpression) + for line in frame.lines { output += "|\(line)\n" } } /// Put the frame readFrame() just produced on screen — for a window that has @@ -957,17 +943,13 @@ private func buildWorld(stem: String) throws -> String { let work = "\(base)/cwd" let configHome = "\(home)/.config" let pardesConfig = "\(configHome)/pardes" - // `New` creates its document under TMPDIR (src/temp_file.zig). Left alone - // that is the per-user /var/folders/... path launchd hands out, which is - // different on every machine and lands verbatim in a pane tag — a golden - // that could only ever pass for whoever generated it. let tmp = "\(base)/tmp" for dir in [base, home, work, configHome, pardesConfig, tmp] { try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) } // The shells are started with `--rcfile /tmp/pardes-osc133.bash`, which - // sources $HOME/.bashrc (src/shell_bin.zig) — so this is what pins the + // sources $HOME/.bashrc (host_io.Shell) — so this is what pins the // prompt to `$ ` and keeps the developer's history out of the capture. try "PS1='$ '\nHISTFILE=\n".write(toFile: "\(home)/.bashrc", atomically: true, encoding: .utf8) // ...and the config is not empty, because the DEFAULT shell is fish, whose diff --git a/test/ninep.py b/test/ninep.py new file mode 100644 index 00000000..b128cc8a --- /dev/null +++ b/test/ninep.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +import json +import os +import socket +import struct + + +def string(value): + data = value.encode() if isinstance(value, str) else value + return struct.pack(' self.msize: + raise ValueError('request exceeds negotiated message size') + self.socket.sendall(packet) + size, answer, received_tag = struct.unpack(' count: + raise OSError('invalid 9P read length') + return reply[4:] + + def read(self, path): + fid = self.open(path) + try: + data = bytearray() + while chunk := self.read_fid(fid, len(data)): + data.extend(chunk) + return bytes(data) + finally: + self.close(fid) + + def screen(self): + return json.loads(self.read('/self/screen')) + + def write(self, path, data, truncate=False): + fid = self.open(path, 1 | (16 if truncate else 0)) + try: + offset = 0 + while offset < len(data): + chunk = data[offset:offset + self.msize - 23] + reply = self.rpc(118, struct.pack(' len(data): + raise OSError('invalid 9P directory entry') + length = struct.unpack_from(' size + 2: + raise OSError('invalid directory name length') + names.append(data[offset + 43:offset + 43 + length].decode()) + offset += size + 2 + return names diff --git a/test/output.zig b/test/output.zig new file mode 100644 index 00000000..577b8e73 --- /dev/null +++ b/test/output.zig @@ -0,0 +1,855 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const config = pardes.config; +const builtins = pardes.builtins; +const modal = pardes.modal; + +const Pardes = pardes.Pardes; +const Key = pardes.Key; +const platform = pardes.platform; +const font_picker = pardes.font_picker; +const fonts = if (font_picker) pardes.fonts else struct {}; + +test "location results sort filenames and numeric positions while remapping their anchor" { + const before = "z.zig:1:1 z\nb.zig:10:2-3 ten\nb.zig:2:10-12 two-b\nb.zig:2:2-3 two-a\na.zig:99:1 a\nb.zig:2:2-3 duplicate\n"; + const after = "a.zig:99:1 a\nb.zig:2:2-3 two-a\nb.zig:2:2-3 duplicate\nb.zig:2:10-12 two-b\nb.zig:10:2-3 ten\nz.zig:1:1 z\n"; + for ([_]panes.Output.Origin{ .search, .{ .cmd = .Grep }, .{ .query = .references }, .{ .query = .diagnostics } }) |origin| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", origin, "", try p.gpa.dupe(u8, before), 2); + const results = p.panes[source.search_pane.?].?; + try std.testing.expectEqualStrings(after, results.file.?.content); + try std.testing.expectEqual(@as(usize, 3), source.search_row.?); + } +} + +test "ranked choices and prose retain their supplied order" { + const text = "z.zig:10:1 first\na.zig:2:1 second\n"; + for ([_]pardes.lsp.Kind{ .completion, .hover, .code_action }) |kind| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = kind }, "", try p.gpa.dupe(u8, text), null); + const result = for (p.panes) |slot| { + const pane = slot orelse continue; + const file = pane.file orelse continue; + if (file.output != null) break file; + } else return error.MissingResults; + try std.testing.expectEqualStrings(text, result.content); + } +} + +test "reference Look selects the next sorted row from the captured request and keeps navigation there" { + if (!pardes.lsp.supports.contains(.references)) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "m.zig", .data = "const item = 0;\n_ = item;\n_ = item;\n_ = item;\n_ = item;\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a.zig", .data = "a\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "z.zig", .data = "z\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/m.zig", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = p.panes[0].?; + const response = "m.zig:5:5-8 later\nz.zig:1:1 last-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\na.zig:1:1 first-file\nm.zig:4:5-8 next\n"; + const sorted = "a.zig:1:1 first-file\nm.zig:1:7-10 earlier\nm.zig:3:5-8 current\nm.zig:4:5-8 next\nm.zig:5:5-8 later\nz.zig:1:1 last-file\n"; + source.cur_row = 2; + source.cur_col = 4; + p.lspRequest(0, .references, ""); + const request = p.lsp_wait.?.id; + source.cur_row = 4; + p.lspResponse(request, response); + const rid = source.search_pane orelse return error.MissingResults; + const results = p.panes[rid].?; + try std.testing.expectEqualStrings(sorted, results.file.?.content); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(@as(i32, 3), source.cur_row); + try std.testing.expect(source.vsel.active); + try std.testing.expectEqual(@as(i32, 4), @min(source.cur_col, source.vsel.col)); + try std.testing.expectEqual(@as(i32, 7), @max(source.cur_col, source.vsel.col)); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); + try std.testing.expectEqual(@as(usize, 3), source.search_row.?); + try std.testing.expectEqual(results.serial, p.look_walk_owner.?); + try std.testing.expectEqual(@as(i32, 3), results.look_at.?.row); + const available = p.freeSlot(); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(@as(i32, 4), results.cur_row); + try std.testing.expectEqual(available, p.freeSlot()); + p.update(.{ .key = .{ .cp = 'N' } }); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); + + p.active = 0; + source.cur_row = 4; + source.cur_col = 4; + p.lspRequest(0, .references, ""); + p.lspResponse(p.lsp_wait.?.id, response); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/z.zig")); + try std.testing.expectEqual(@as(i32, 5), results.cur_row); + try std.testing.expectEqual(@as(usize, 5), source.search_row.?); + const last = p.active; + p.lspRequest(last, .references, ""); + p.lspResponse(p.lsp_wait.?.id, response); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/a.zig")); + const wrapped = p.panes[p.panes[last].?.search_pane.?].?; + try std.testing.expectEqual(@as(i32, 0), wrapped.cur_row); + try std.testing.expectEqual(wrapped.serial, p.look_walk_owner.?); +} + +test "location sorting leaves mixed prose alone and preserves newline shape" { + const cases = [_]struct { before: []const u8, after: []const u8, anchor: usize }{ + .{ + .before = "heading\nz.zig:10:1 z\nnot a location\nx file.zig:2:10 x\nx file.zig:2:2 y\n\n", + .after = "heading\nz.zig:10:1 z\nnot a location\nx file.zig:2:10 x\nx file.zig:2:2 y\n\n", + .anchor = 1, + }, + .{ + .before = "z.zig:10:1 z\nx file.zig:2:10 x\nx file.zig:2:2 y", + .after = "x file.zig:2:2 y\nx file.zig:2:10 x\nz.zig:10:1 z", + .anchor = 1, + }, + }; + for (cases) |case| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .search, "", try p.gpa.dupe(u8, case.before), 1); + try std.testing.expectEqualStrings(case.after, p.panes[source.search_pane.?].?.file.?.content); + try std.testing.expectEqual(case.anchor, source.search_row.?); + } +} + +test "a single language location opens the full spaced filename" { + if (!pardes.lsp.supports.contains(.references)) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "from.zig", .data = "const item = 0;\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "with space.zig", .data = "first\nsecond\n" }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/from.zig", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path }); + defer p.deinit(); + p.lspRequest(0, .references, ""); + p.lspResponse(p.lsp_wait.?.id, "with space.zig:2:1 second\n"); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/with space.zig")); + try std.testing.expectEqual(@as(i32, 1), p.panes[p.active].?.cur_row); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file) |file| try std.testing.expect(file.output == null); + } +} + +test "result locations stop before preview text and retain full addresses" { + const cases = [_]struct { row: []const u8, path: []const u8, line: usize, col: usize, address: []const u8 }{ + .{ .row = "a.zig:2 see other.zig:10:1", .path = "a.zig", .line = 2, .col = 0, .address = "a.zig:2" }, + .{ .row = "a.zig:2:10-12 see other.zig:1:1", .path = "a.zig", .line = 2, .col = 10, .address = "a.zig:2:10-12" }, + .{ .row = "with space.zig:100:2 café", .path = "with space.zig", .line = 100, .col = 2, .address = "with space.zig:100:2" }, + .{ .row = "@p3:20:1 pane", .path = "@p3", .line = 20, .col = 1, .address = "@p3:20:1" }, + .{ .row = "/n/peer/file.zig:5-8 span", .path = "/n/peer/file.zig", .line = 5, .col = 0, .address = "/n/peer/file.zig:5-8" }, + }; + for (cases) |case| { + const got = panes.Output.location(case.row); + try std.testing.expectEqualStrings(case.path, got.path); + try std.testing.expectEqual(case.line, got.at.line); + try std.testing.expectEqual(case.col, got.at.col); + try std.testing.expectEqualStrings(case.address, case.row[0..got.end]); + } +} + +test "reordered identical results preserve the selected row and warm line index" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + const sorted = "a.zig:2:1 first\na.zig:10:1 second\n"; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "", try p.gpa.dupe(u8, sorted), null); + const result = p.panes[source.search_pane.?].?; + const file = &result.file.?; + const index = try panes.File.lineIndex(p.gpa, file); + const content = file.content.ptr; + const revision = file.revision; + result.cur_row = 1; + result.cur_col = 0; + result.vsel = .{ .active = true, .explicit = true, .row = 1, .col = 9 }; + result.select = true; + const selected = result.vsel; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "new request", try p.gpa.dupe(u8, "a.zig:10:1 second\na.zig:2:1 first\n"), 0); + try std.testing.expectEqualStrings(sorted, file.content); + try std.testing.expectEqual(content, file.content.ptr); + try std.testing.expectEqual(index.ptr, file.line_starts.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expect(result.select); + try std.testing.expectEqual(@as(i32, 1), result.cur_row); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); +} + +test "result sorting allocation failures preserve the previous answer and navigation" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "a.zig:1:1 keep\n"), 0); + const id = source.search_pane.?; + const result = p.panes[id].?; + const content = result.file.?.content.ptr; + const revision = result.file.?.revision; + const owner = p.look_walk_owner; + var preview: [4096]u8 = @splat('x'); + for (0..2) |failure| { + var failing = std.testing.FailingAllocator.init(p.gpa, .{ .fail_index = failure }); + const original_scratch = p.scratch; + p.scratch = .init(failing.allocator()); + defer { + p.scratch.deinit(); + p.scratch = original_scratch; + } + const incoming = try std.fmt.allocPrint(p.gpa, "z.zig:2:1 {s}\na.zig:1:1 {s}\n", .{ &preview, &preview }); + try std.testing.expectError(error.OutOfMemory, panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "after", incoming, 1)); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqual(content, result.file.?.content.ptr); + try std.testing.expectEqual(revision, result.file.?.revision); + try std.testing.expectEqualStrings("before", result.file.?.output.?.arg()); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 0), source.search_row.?); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(owner, p.look_walk_owner); + } +} + +test "Find sorts whole filenames and next location uses numeric columns" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile(""); + try panes.Output.fillResults(p, 0, "/tmp", .{ .cmd = .Find }, "", try p.gpa.dupe(u8, "x:2\nx:10\n"), 0); + const result = p.panes[source.search_pane.?].?; + try std.testing.expectEqualStrings("x:10\nx:2\n", result.file.?.content); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + const rows = "with space.zig:2:2-3 first\nwith space.zig:2:10-12 next\nwith space.zig:10:1 later"; + try std.testing.expectEqual(@as(usize, 1), panes.Output.nextResult(rows, "with space.zig", .{ .line = 2, .col = 3 })); + try std.testing.expectEqual(@as(usize, 2), panes.Output.nextResult(rows, "with space.zig", .{ .line = 2, .col = 10 })); + try std.testing.expectEqual(@as(usize, 0), panes.Output.nextResult(rows, "with space.zig", .{ .line = 10, .col = 1 })); +} + +test "result stepping reaches an unterminated last row" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("one\ntwo\n"); + const rows = try std.fmt.allocPrint(p.gpa, "{s}:1:1 first\n{s}:2:1 last", .{ source.file.?.path, source.file.?.path }); + try panes.Output.fillResults(p, 0, "/tmp", .search, "", rows, 0); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + const result = p.panes[source.search_pane.?].?; + try std.testing.expectEqual(@as(i32, 1), result.cur_row); +} + +test "Mini uses OS source precedence and refreshes one independent snapshot" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "build.zig", .data = "x\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try p.panes[0].?.setOwnedCwd(dir); + try std.testing.expect(p.executeBuiltinLine(0, "Mini build.zig")); + const id = p.active; + try std.testing.expect(id != 0); + const mini = p.panes[id].?; + try std.testing.expect(mini.terminal == null); + try std.testing.expectEqualStrings("⠁\n", mini.file.?.content); + try std.testing.expect(std.mem.startsWith(u8, mini.file.?.mini.?.source, dir)); + const next = p.freeSlot(); + const body = mini.file.?.content.ptr; + try std.testing.expect(p.executeBuiltinLine(id, "Mini build.zig")); + try std.testing.expectEqual(id, p.active); + try std.testing.expectEqual(body, mini.file.?.content.ptr); + try std.testing.expectEqual(next, p.freeSlot()); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "build.zig", .data = "xx\n" }); + try std.testing.expectEqualStrings("⠁\n", mini.file.?.content); + try std.testing.expect(p.executeBuiltinLine(id, "Mini build.zig")); + try std.testing.expectEqual(id, p.active); + try std.testing.expectEqualStrings("⠉\n", mini.file.?.content); + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| try std.testing.expect(watch.pane != id), + else => {}, + }; + try std.testing.expect(p.executeBuiltinLine(id, "Save")); + try std.testing.expect(mini.prompt == .save); +} + +test "Mini dump preserves its exact virtual snapshot and body writes detach colors" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("pub fn start() void {}\n"); + var command_buf: [128]u8 = undefined; + const command = try std.fmt.bufPrint(&command_buf, "Mini /virtual/pane/{d}/body", .{source.serial}); + try std.testing.expect(p.executeBuiltinLine(0, command)); + const id = p.active; + const before = p.panes[id].?.file.?; + try std.testing.expect(before.mini != null); + try p.dumpState(); + panes.File.setContent(p, &source.file.?, try p.gpa.dupe(u8, "changed\n")); + const restored = try Pardes.initFromDump(std.testing.allocator, .{ .tty_only = true }, p.dump_out.?); + defer restored.deinit(); + const mini = restored.panes[restored.active].?; + try std.testing.expectEqualStrings(before.content, mini.file.?.content); + try std.testing.expectEqualStrings(before.mini.?.source, mini.file.?.mini.?.source); + try std.testing.expectEqualSlices(u8, before.mini.?.colors, mini.file.?.mini.?.colors); + try std.testing.expect(before.content.ptr != mini.file.?.content.ptr); + while (restored.nextEffect()) |effect| switch (effect) { + .watch => |watch| try std.testing.expect(watch.pane != restored.active), + else => {}, + }; + var body_buf: [128]u8 = undefined; + const body_path = try std.fmt.bufPrint(&body_buf, "/n/self/pane/{d}/body", .{mini.serial}); + const body = try pardes.filesystem.read(restored, body_path); + defer restored.gpa.free(body); + try std.testing.expectEqualStrings(before.content, body); + try pardes.filesystem.write(restored, body_path, "replacement\n"); + try std.testing.expectEqualStrings("replacement\n", mini.file.?.content); + try std.testing.expect(mini.file.?.mini == null); +} + +test "Mini syntax colors survive toggles themes and rendering without source access" { + if (!pardes.syntax.enabled) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "demo.zig", .data = "pub fn main() void {\n if (true) return;\n}\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + try p.panes[0].?.setOwnedCwd(dir); + p.settings.colors = false; + 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; + const position = modal.positionAt(mini.file.?.content, keyword); + const column = panes.File.displayWidth(modal.lineSlice(mini.file.?.content, position.row)[0..position.col]); + try tmp.dir.deleteFile(std.testing.io, "demo.zig"); + const colors = mini.file.?.mini.?.colors.ptr; + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for ([_]bool{ false, true, false, true }) |enabled| { + p.settings.colors = enabled; + mini.file.?.syntax_dirty = true; + const surface = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(mini) + @as(u16, @intCast(column)); + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(position.row)); + if (enabled) try std.testing.expectEqualDeep(pardes.Color{ .rgb = p.theme().kw }, surface.at(x, y).style.fg); + try std.testing.expectEqual(colors, mini.file.?.mini.?.colors.ptr); + try std.testing.expectEqual(@as(usize, 0), mini.file.?.highlights.len); + _ = frame.reset(.retain_capacity); + try std.testing.expect(p.executeBuiltinLine(id, "NextColor")); + } +} + +test "Mini restore rejects inconsistent metadata before publishing a pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const id = p.freeSlot().?; + const serial = p.next_serial; + for ([_]pardes.dump.File{ + .{ .content = "x", .mini_source = "/source.zig", .mini_colors_b64 = "AA==" }, + .{ .content = "x", .origin = "Mini", .mini_source = "/source.zig", .mini_colors_b64 = "" }, + .{ .content = "x", .origin = "Mini", .mini_source = "/source.zig", .mini_colors_b64 = "BQ==" }, + .{ .content = "x", .origin = "Mini", .mini_colors_b64 = "AA==" }, + }) |saved| { + try std.testing.expectError(error.InvalidMini, panes.File.restore(p, id, .{ .kind = .file, .tag = "", .body = "", .file = saved })); + try std.testing.expect(p.panes[id] == null); + try std.testing.expectEqual(serial, p.next_serial); + } +} + +test "Mini failures leave its existing snapshot and focus unchanged" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + 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); + 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.expectEqual(id, p.active); + try std.testing.expectEqual(body, mini.file.?.content.ptr); + try std.testing.expectEqual(next, p.freeSlot()); +} + +test "Config prints the startup path and refreshes its one output" { + const path = "/home/pardes-test/.config/pardes/init"; + const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + const opened = p.active; + const out = p.panes[opened].?.file.?; + for ([_][]const u8{ + "Startup config: " ++ path ++ "\n", + "Theme: helix\n", + "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n", + "Shell effective (last spawn): (none)\n", + "Shell pending: on\n", + }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null); + try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path)); + try std.testing.expectEqual(panes.Output.Origin{ .cmd = .Config }, out.output.?.from); + + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + try std.testing.expectEqual(opened, p.active); + var buffers: usize = 0; + for (p.panes) |slot| { + const f = (slot orelse continue).file orelse continue; + const origin = (f.output orelse continue).from; + buffers += @intFromBool(std.meta.eql(origin, panes.Output.Origin{ .cmd = .Config })); + } + try std.testing.expectEqual(@as(usize, 1), buffers); +} + +test "Config reports the absence of a per-user config path" { + const p = try Pardes.init(std.testing.allocator, .{}); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(0, "Config")); + const report = p.panes[p.active].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, report, "no per-user config path") != null); +} + +test "EffectCode lists readable virtual sources used by this backend" { + if (comptime !builtins.EffectCode.enabled) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "EffectCode PanelSlide")); + const out = p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings(config.effect_code_buffer, std.fs.path.basename(out.path)); + try std.testing.expectEqual(panes.Output.Origin{ .cmd = .EffectCode }, out.output.?.from); + try std.testing.expectEqualStrings("PanelSlide", out.output.?.arg()); + try std.testing.expect(out.content.len < 1024); + try std.testing.expect(std.mem.indexOf(u8, out.content, "/virtual/src/layout.zig\n") != null); + const backend_source = switch (platform) { + .tty => "/virtual/src/tty/panel_compositor.zig", + .gui => if (@import("pardes_config").gui_shader_sources_prebuilt) + "/virtual/shaders/prebuilt/ui.vert.glsl" + else + "/virtual/shaders/ui.vert.glsl", + .macos => "/virtual/src/macos/Sources/ScenePostprocessor.swift", + .web, .esp32p4 => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, out.content, backend_source) != null); + var paths = std.mem.tokenizeScalar(u8, out.content, '\n'); + var count: usize = 0; + while (paths.next()) |path| { + if (!std.mem.startsWith(u8, path, "/virtual/")) continue; + const expected = pardes.filesystem.sourceBytes(path["/virtual/".len..]) orelse return error.MissingEffectSource; + const bytes = try pardes.filesystem.read(p, path); + defer p.gpa.free(bytes); + try std.testing.expectEqualStrings(expected, bytes); + count += 1; + } + try std.testing.expect(count >= 3); + const report = p.active; + p.lookAt(report, backend_source); + try std.testing.expect(p.active != report); + const source = p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings(backend_source, source.path); + try std.testing.expectEqualStrings(pardes.filesystem.sourceBytes(backend_source["/virtual/".len..]).?, source.content); +} + +test "every enabled setting builtin mutates the State Config reports" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqual(config.gui_tagline_font_percent, p.settings.font.tagline_percent); + + var font_arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer font_arena.deinit(); + var chosen_font: ?[]const u8 = null; + var buf: [512]u8 = undefined; + for (config.Runtime.settings) |setting| { + if (!setting.enabled(builtins.capabilities)) continue; + const command: []const u8 = switch (setting.action) { + .theme => try std.fmt.bufPrint(&buf, "{s} acme", .{setting.word}), + .shell => try std.fmt.bufPrint(&buf, "{s} fish", .{setting.word}), + .tagline_size => try std.fmt.bufPrint(&buf, "{s} 67", .{setting.word}), + .font => continue, + else => setting.word, + }; + try std.testing.expect(p.executeBuiltinLine(p.active, command)); + } + if (comptime font_picker) { + const before_invalid = p.settings.font.tagline_percent; + try std.testing.expect(p.executeBuiltinLine(p.active, "TaglineSize 0")); + try std.testing.expectEqual(before_invalid, p.settings.font.tagline_percent); + const installed = fonts.list(font_arena.allocator(), null); + if (installed.len > 0) { + chosen_font = installed[0].name; + const command = try std.fmt.bufPrint(&buf, "Font {s}", .{installed[0].name}); + try std.testing.expect(p.executeBuiltinLine(p.active, command)); + } + } + + try std.testing.expect(p.executeBuiltinLine(p.active, "Config")); + const report = p.panes[p.active].?.file.?.content; + for ([_][]const u8{ + "Colors: off\n", + "Wrap: off\n", + "Tagbottom: on\n", + "Debug: on\n", + "Theme: acme\n", + "Shell requested (new panes): fish\n", + }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null); + const transition = if (builtins.capabilities.panel_transitions) + try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{ + config.Runtime.findAction(.{ .transition = p.settings.panel_transition }).?.word, + }) + else + "Panel transition: unsupported\n"; + try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); + const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; + for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status }); + try std.testing.expect(std.mem.indexOf(u8, report, line) != null); + } + const tagline = if (!builtins.capabilities.tagline_font_size) + "TaglineSize: unsupported\n" + else + try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{ + p.settings.font.tagline_percent, + if (builtins.capabilities.font_picker) "" else " (build-time only)", + }); + try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null); + if (chosen_font) |name| { + try std.testing.expect(std.mem.indexOf(u8, report, name) != null); + try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null); + } +} + +fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pattern: []const u8) !usize { + const rows = try p.gpa.dupe(u8, + \\build.zig:1:1 first + \\(mise.toml) and build.zig.zon:3:2-9 two on one row + \\nothing look-able on this row at all + \\uucode_config.zig:7:1 last + \\ + ); + try panes.Output.fillResults(p, id, cwd, .search, pattern, rows, null); + return p.panes[id].?.search_pane orelse error.MissingResults; +} + +const ProjectPaths = struct { cwd: []const u8, boot: []const u8 }; + +fn projectPaths(cwd_buf: *[4096]u8, path_buf: *[4096]u8) !ProjectPaths { + const raw = std.c.getcwd(cwd_buf, cwd_buf.len) orelse return error.GetCwdFailed; + const cwd = std.mem.span(@as([*:0]u8, @ptrCast(raw))); + return .{ .cwd = cwd, .boot = try std.fmt.bufPrint(path_buf, "{s}/mise.toml", .{cwd}) }; +} + +fn selectedOutputText(pane: *const pardes.Pane) ?[]const u8 { + const file = pane.file orelse return null; + if (!pane.vsel.active or pane.vsel.row != pane.cur_row or pane.cur_row < 0) return null; + const line = modal.lineSlice(file.content, @intCast(pane.cur_row)); + const lo: usize = @intCast(@max(0, @min(pane.vsel.col, pane.cur_col))); + const hi: usize = @intCast(@max(0, @max(pane.vsel.col, pane.cur_col))); + if (lo >= line.len) return ""; + return line[lo..@min(line.len, hi + 1)]; +} + +test "n/N selects one output location per row and opens nothing" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + const rid = try walkFixture(p, p.active, paths.cwd, "one"); + const results = p.panes[rid].?; + const panes_before = p.freeSlot(); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(panes_before, p.freeSlot()); + try std.testing.expect(results.vsel.active and results.vsel.explicit); + try std.testing.expectEqualStrings("build.zig:1:1", selectedOutputText(results) orelse ""); + try std.testing.expectEqual(panes.Output.Grain.line, panes.Output.grain(results.file.?.output)); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 1), results.cur_row); + try std.testing.expectEqualStrings("mise.toml", selectedOutputText(results) orelse ""); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 3), results.cur_row); // row 2 is not look-able + try std.testing.expectEqualStrings("uucode_config.zig:7:1", selectedOutputText(results) orelse ""); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(i32, 0), results.cur_row); // ring seam + + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(p.freeSlot() != panes_before); + try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig")); + try std.testing.expectEqual(panes.Output.Grain.word, panes.Output.grain(p.panes[p.active].?.file.?.output)); +} + +test "n/N resumes the result output whose Look moved focus away" { + if (platform == .web) return; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "look-owner.txt", + .data = "alpha target\nbeta target\ngamma target\n", + }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/look-owner.txt", .{tmp.sub_path}); + const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + + try std.testing.expect(p.executeBuiltinLine(0, "Look target")); + const rid = p.panes[0].?.search_pane orelse return error.MissingResults; + const results = p.panes[rid].?; + const first = results.cur_row; + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expect(results.look_at != null); + try std.testing.expectEqual(results.serial, p.look_walk_owner orelse return error.MissingLookOwner); + + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(first + 1, results.cur_row); + p.update(.{ .key = .{ .cp = 'N' } }); + try std.testing.expectEqual(first, results.cur_row); + + // A later no-match answer has no position to resume and therefore cannot + // steal the provenance established by the Look above. + const owner = results.serial; + const dir = std.fs.path.dirname(path) orelse "."; + try panes.Output.fillResults( + p, + 0, + dir, + .search, + "no-such-result", + try p.gpa.dupe(u8, ""), + null, + ); + try std.testing.expectEqual(owner, p.look_walk_owner.?); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(rid, p.active); + try std.testing.expectEqual(first + 1, results.cur_row); + + // Emptying the owner itself keeps its identity as the ring's starting + // point. Deleting it then leaves a stale serial, never an id that can bind + // to the unrelated pane subsequently allocated in the same slot. + try panes.Output.fillResults(p, 0, dir, .search, "target", try p.gpa.dupe(u8, ""), null); + try std.testing.expectEqual(owner, p.look_walk_owner.?); + try std.testing.expect(p.executeBuiltinLine(rid, "Del")); + try std.testing.expect(p.paneBySerial(owner) == null); + const replacement = try p.newShell(rid, ""); + try std.testing.expect(replacement.serial != owner); + try std.testing.expect(p.paneBySerial(owner) == null); +} + +test "N exactly reverses n across output panes and the ring seam" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + const first = try walkFixture(p, p.active, paths.cwd, "one"); + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); + const second = try walkFixture(p, p.active, paths.cwd, "two"); + try std.testing.expect(first != second); + + const Mark = struct { pane: usize, row: i32, col: i32 }; + const here = struct { + fn at(pp: *Pardes) Mark { + const pane = pp.panes[pp.active].?; + return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col }; + } + }.at; + p.update(.{ .key = .{ .cp = 'n' } }); + const base = here(p); + var trail: [12]Mark = undefined; + for (&trail) |*mark| { + p.update(.{ .key = .{ .cp = 'n' } }); + mark.* = here(p); + } + var i = trail.len; + while (i > 0) { + i -= 1; + p.update(.{ .key = .{ .cp = 'N' } }); + const want = if (i == 0) base else trail[i - 1]; + try std.testing.expectEqual(want, here(p)); + } + var saw_first = false; + var saw_second = false; + for (trail) |mark| { + saw_first = saw_first or mark.pane == first; + saw_second = saw_second or mark.pane == second; + } + try std.testing.expect(saw_first and saw_second); +} + +test "n/N selects command outputs by whole row" { + if (platform == .web) return; + var cwd_buf: [4096]u8 = undefined; + var path_buf: [4096]u8 = undefined; + const paths = try projectPaths(&cwd_buf, &path_buf); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 40, .file = paths.boot }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 100, .rows = 40 } }); + + try std.testing.expect(p.executeBuiltinLine(p.active, "ThemeSel")); + const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList; + const themes = p.panes[tid].?; + try std.testing.expect(panes.Output.fileTraits(themes.file.?.output).commands); + p.update(.{ .key = .{ .cp = 'n' } }); + const row0 = std.mem.trimEnd(u8, modal.lineSlice(themes.file.?.content, 0), " \t\r"); + try std.testing.expectEqualStrings(row0, selectedOutputText(themes) orelse ""); + const theme_before = p.settings.theme; + p.update(.{ .key = .{ .cp = 'n' } }); + p.update(.{ .key = .{ .cp = Key.tab } }); + try std.testing.expect(p.settings.theme != theme_before); + + try std.testing.expect(p.executeBuiltinLine(0, "Look build.zig")); + p.update(.tick); + try std.testing.expect(p.active != 0); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(@as(usize, 0), p.active); +} + +test "identical result refresh keeps warm content caches without allocation" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + p.settings.colors = true; + const text = "a.zig:1:1 pub fn first() void {}\na.zig:2:1 if (true) return;\n"; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, text), 0); + const id = source.search_pane orelse return error.MissingResults; + const result = p.panes[id].?; + const file = &result.file.?; + result.cur_row = 1; + result.cur_col = 12; + result.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 2 }; + result.select = true; + file.scroll = 1; + result.hscroll = 3; + panes.File.refreshHighlights(p); + const index = try panes.File.lineIndex(p.gpa, file); + const styles = try std.testing.allocator.dupe(u8, file.highlights); + defer std.testing.allocator.free(styles); + if (@import("pardes_config").syntax_zig_grammar) try std.testing.expect(styles.len > 0); + const revision = file.revision; + const saved_revision = file.saved_revision; + const body_ptr = file.content.ptr; + const style_ptr = file.highlights.ptr; + const highlight_start = file.highlight_start; + const selected = result.vsel; + const event_node = pardes.filesystem.Node.of(result.serial, .event); + const opened = pardes.filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); + try std.testing.expectEqual(.ok, opened.status); + defer _ = pardes.filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle }); + + for (0..32) |_| { + const incoming = try p.gpa.dupe(u8, text); + const allocations = allocator.allocations; + const freed = allocator.freed_bytes; + const deallocations = allocator.deallocations; + allocator.fail_index = allocator.alloc_index; + defer allocator.fail_index = std.math.maxInt(usize); + p.active = id; + source.search_pane = null; + source.search_row = null; + p.look_walk_owner = null; + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "after", incoming, 1); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqual(saved_revision, file.saved_revision); + try std.testing.expectEqual(body_ptr, file.content.ptr); + try std.testing.expectEqual(index.ptr, file.line_starts.ptr); + try std.testing.expectEqual(style_ptr, file.highlights.ptr); + try std.testing.expectEqual(highlight_start, file.highlight_start); + try std.testing.expectEqualSlices(u8, styles, file.highlights); + try std.testing.expect(!file.syntax_dirty); + panes.File.refreshHighlights(p); + try std.testing.expectEqual(allocations, allocator.allocations); + try std.testing.expectEqual(freed + text.len, allocator.freed_bytes); + try std.testing.expectEqual(deallocations + 1, allocator.deallocations); + try std.testing.expect(!allocator.has_induced_failure); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expect(result.select); + try std.testing.expectEqual(@as(i32, 1), result.cur_row); + try std.testing.expectEqual(@as(i32, 12), result.cur_col); + try std.testing.expectEqual(@as(usize, 1), file.scroll); + try std.testing.expectEqual(@as(i32, 3), result.hscroll); + try std.testing.expectEqualStrings("after", file.output.?.arg()); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + try std.testing.expectEqual(result.serial, p.look_walk_owner.?); + try std.testing.expect(p.fs.panes[id].events.empty()); + } + + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "changed", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expectEqual(revision + 1, file.revision); + try std.testing.expectEqualStrings("short\n", file.content); + try std.testing.expectEqualStrings("changed", file.output.?.arg()); + try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + try std.testing.expectEqual(@as(usize, 0), file.highlights.len); + try std.testing.expect(file.syntax_dirty); + try std.testing.expect(!result.vsel.active and !result.select); + try std.testing.expectEqual(@as(i32, 0), result.cur_row); + try std.testing.expectEqual(@as(i32, 0), result.cur_col); + try std.testing.expectEqual(@as(usize, 0), file.scroll); + try std.testing.expectEqual(@as(i32, 0), result.hscroll); + try std.testing.expect(source.search_row == null); + const events = &p.fs.panes[id].events; + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "KD")); + events.pop(); + try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "KI")); + events.pop(); + try std.testing.expect(events.empty()); +} + +test "rejected result refresh frees incoming text and leaves the existing output intact" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const source = try p.setTestFile("notes\n"); + try panes.Output.fillResults(p, 0, "/tmp", .{ .query = .references }, "before", try p.gpa.dupe(u8, "keep\n"), 1); + const id = source.search_pane.?; + const result = p.panes[id].?; + const file = &result.file.?; + const body_ptr = file.content.ptr; + const revision = file.revision; + const free_slot = p.freeSlot(); + var oversized: [panes.Output.max_arg + 1]u8 = @splat('x'); + for ([_]bool{ false, true }) |oom| { + const incoming = try p.gpa.dupe(u8, "discard\n"); + const freed = allocator.freed_bytes; + const deallocations = allocator.deallocations; + if (oom) allocator.fail_index = allocator.alloc_index; + defer allocator.fail_index = std.math.maxInt(usize); + try std.testing.expectError(if (oom) error.OutOfMemory else error.ArgumentTooLong, panes.Output.fillResults( + p, + 0, + "/tmp", + if (oom) .search else .{ .query = .references }, + if (oom) "new output" else &oversized, + incoming, + 0, + )); + try std.testing.expectEqual(freed + "discard\n".len, allocator.freed_bytes); + try std.testing.expectEqual(deallocations + 1, allocator.deallocations); + try std.testing.expectEqual(body_ptr, file.content.ptr); + try std.testing.expectEqual(revision, file.revision); + try std.testing.expectEqualStrings("keep\n", file.content); + try std.testing.expectEqualStrings("before", file.output.?.arg()); + try std.testing.expectEqual(free_slot, p.freeSlot()); + try std.testing.expectEqual(id, source.search_pane.?); + try std.testing.expectEqual(@as(usize, 1), source.search_row.?); + try std.testing.expectEqual(@as(usize, 0), p.active); + } + try std.testing.expect(allocator.has_induced_failure); +} diff --git a/test/panes.zig b/test/panes.zig new file mode 100644 index 00000000..7d7a9ab7 --- /dev/null +++ b/test/panes.zig @@ -0,0 +1,2743 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const layout = pardes.layout; +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const Key = pardes.Key; +const config = pardes.config; +const modal = pardes.modal; +const dump = pardes.dump; +const image = pardes.image; +const syntax = pardes.syntax; +const filesystem = pardes.filesystem; + +test "terminal overlay recoloring never materializes unrelated history" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 32, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + for (0..1000) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32mcaf\xc3\xa9 \xe7\x95\x8c\x1b[0m\r\n" } }); + pane.mode = .normal; + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "edited history") }; + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + try testing.expect(p.shell_rows.pane == null); + + 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); + const surface = try p.render(frame.allocator()); + try testing.expect(p.shell_rows.pane == null); + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const cell = surface.at(rect.x + config.GUTTER, body_y); + try testing.expectEqualStrings("c", cell.grapheme()); + try testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg); +} + +test "terminal output keeps following new rows after scrollback eviction" { + 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(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + pane.tty_filter = false; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + var evictions: usize = 0; + for (0..3) |phase| { + if (phase > 0) { + pane.mode = .normal; + panes.Terminal.scrollGrid(pane, -30); + try std.testing.expect(pages.scrollbar().offset < pages.scrollbar().total - pane.rows); + panes.Terminal.enterTty(p, 0); + } + for (phase * 20_000..(phase + 1) * 20_000) |n| { + var buf: [64]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "\x1b[32mrow-{d:0>5}\x1b[0m\r\n", .{n}); + const previous = pages.scrollbar().total; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + if (pages.scrollbar().total < previous) evictions += 1; + } + try std.testing.expect(evictions > phase); + 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); + 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}); + try std.testing.expect(std.mem.indexOf(u8, full, "row-00000") == null); + try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, full, "\n"), last)); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER; + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pane.rows - 2; + for (last, 0..) |byte, col| { + const cell = surface.at(x + @as(u16, @intCast(col)), y); + try std.testing.expectEqualStrings(&.{byte}, cell.grapheme()); + try std.testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg); + } + while (p.nextEffect()) |_| {} + p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const effect = p.nextEffect() orelse return error.MissingTerminalInput; + try std.testing.expectEqual(.write, std.meta.activeTag(effect)); + try std.testing.expectEqual(@as(u8, 0), effect.write.pane); + try std.testing.expectEqualStrings("f", effect.write.bytes.slice()); + try std.testing.expect(p.nextEffect() == null); + } + const replay = &pane.terminal.?.replay; + try std.testing.expectEqual(replay.bytes.len, replay.len); + try std.testing.expect(replay.head > 0); +} + +test "terminal edit stays on its surviving row when old scrollback is evicted" { + 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(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var next: usize = 0; + while (next < 3000) { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next}); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + next += 1; + if (pages.scrollbar().total > pages.pages.first.?.rows() + pane.rows + 10) break; + } + try std.testing.expect(next < 3000); + var last_buf: [16]u8 = undefined; + const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{next - 1}); + pane.mode = .normal; + const original_row: i32 = @intCast(pages.scrollbar().total - 2); + pane.ovl = .{ .row = original_row, .rows = 1, .text = try gpa.dupe(u8, "my unsent edit") }; + pane.cur_row = original_row; + pane.cur_col = 3; + pane.cur_pinned = true; + var removed: usize = 0; + for (next..next + 3000) |n| { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{n}); + const previous = pages.scrollbar().total; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + if (pages.scrollbar().total < previous) { + removed = previous + 1 - pages.scrollbar().total; + break; + } + } + try std.testing.expect(removed > 0 and removed < original_row); + 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))); + try std.testing.expectEqual(row, pane.ovl.?.row); + try std.testing.expectEqual(row, pane.cur_row); + try std.testing.expectEqualStrings("my unsent edit", pane.ovl.?.text); +} + +test "terminal edits and undo survive partial history clearing and whole-screen loss" { + 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| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + if (action == .reset_alternate) + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h" } }); + p.update(.{ .output = .{ .pane = 0, .bytes = "old row\r\n" ** 40 } }); + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + const row: i32 = @as(i32, @intCast(pages.total_rows - pane.rows)) + + @as(i32, if (action == .partial_clear_history) -1 else 2); + pane.mode = .normal; + pane.ovl = .{ .row = row, .rows = if (action == .partial_clear_history) 3 else 2, .text = try gpa.dupe(u8, "edit one\nedit two") }; + pane.cur_row = row + 1; + pane.cur_col = 4; + pane.cur_pinned = true; + pane.vsel = .{ .active = true, .row = row, .col = 1 }; + panes.Terminal.pushUndo(p, pane); + pane.ed_redo[0] = panes.Terminal.snap(p, pane) orelse return error.SnapshotFailed; + pane.ed_redo_len = 1; + if (action == .hidden_undo) { + gpa.free(pane.ovl.?.text); + pane.ovl = null; + panes.Terminal.enterTty(p, 0); + } + const bytes = switch (action) { + .clear_history, .partial_clear_history => "\x1b[3J", + .reset_primary, .reset_alternate => "\x1bc", + .evict_all, .hidden_undo => "new row\r\n" ** 4000, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + const expected: i32 = if (action == .clear_history) 2 else 0; + if (pane.ovl) |edit| { + try std.testing.expectEqual(expected, edit.row); + try std.testing.expectEqual(@as(i32, if (action == .clear_history or action == .partial_clear_history) 2 else 1), edit.rows); + try std.testing.expectEqualStrings("edit one\nedit two", edit.text); + try std.testing.expectEqual(expected + 1, pane.cur_row); + try std.testing.expectEqual(expected, pane.vsel.row); + } + for ([_]panes.Terminal.Snapshot{ pane.ed_undo[0], pane.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); + 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; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "top\r\nsecond\r\nthird" } }); + pane.ovl = .{ .row = 1, .rows = 1, .text = try gpa.dupe(u8, "unsent") }; + pane.cur_row = 1; + pane.cur_pinned = true; + 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(@as(i32, 1), pane.ovl.?.row); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + try std.testing.expectEqual(@as(i32, 1), pane.ed_undo[0].ovl.?.row); + 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; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.mode = .tty; + const pages = &pane.terminal.?.vt.screens.active.pages; + pages.explicit_max_size = 64 * 1024; + var next: usize = 0; + while (next < 3000) { + var buf: [32]u8 = undefined; + const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next}); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + next += 1; + if (pages.pages.first != pages.pages.last and + pages.pages.last.?.rows() == pages.pages.last.?.capacity().rows) break; + } + try std.testing.expect(next < 3000); + const removed = pages.pages.first.?.rows(); + const old_row: i32 = @intCast(removed + 2); + pane.mode = .normal; + pane.ovl = .{ .row = old_row, .rows = 1, .text = try gpa.dupe(u8, "kept edit") }; + pane.cur_row = old_row; + pane.cur_pinned = true; + 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 }); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + try std.testing.expectEqual(old_total + 1 - removed, pages.total_rows); + try std.testing.expectEqual(@as(i32, 2), pane.ovl.?.row); + try std.testing.expectEqual(@as(i32, 2), pane.cur_row); + try std.testing.expectEqual(@as(i32, 2), pane.ed_undo[0].ovl.?.row); + try std.testing.expectEqualStrings("kept edit", pane.ovl.?.text); +} + +test "tag prompt entry and cancellation preserve exact text and cursor state" { + const Case = struct { key: ?Key = null, save: bool = false, marker: []const u8 }; + const cases = [_]Case{ + .{ .marker = config.search_marker }, + .{ .save = true, .marker = config.save_marker ++ "/dír space/" }, + .{ .key = .{ .cp = '|' }, .marker = config.pipe_marker }, + .{ .key = .{ .cp = '|', .alt = true }, .marker = config.pipe_marker_to }, + .{ .key = .{ .cp = '!' }, .marker = config.pipe_marker_insert }, + .{ .key = .{ .cp = '!', .alt = true }, .marker = config.pipe_marker_append }, + }; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo\n"); + try pane.setOwnedCwd("/dír space"); + const tail = " keep"; + for (cases) |case| { + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; + pane.tag_init = true; + pane.tag_sel = true; + pane.tag_col = 1; + pane.cur_row = 1; + pane.cur_col = 2; + pane.cur_pinned = true; + if (case.key) |key| { + p.update(.{ .key = key }); + } else if (case.save) { + p.startPrompt(pane, .save); + } else { + p.startPrompt(pane, .{ .search = config.search_marker }); + } + try std.testing.expect(pane.tag_edit); + try std.testing.expect(!pane.tag_sel); + try std.testing.expectEqual(.insert, pane.mode); + try std.testing.expectEqualDeep(modal.Normal.State{}, pane.normal); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..tail.len]); + try std.testing.expectEqualStrings(case.marker, pane.tag_tail[tail.len..pane.tag_tail_len]); + try std.testing.expectEqual(pane.file.?.path.len + pane.tag_tail_len, pane.tag_col); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + try std.testing.expectEqual(@as(i32, 2), pane.cur_col); + try std.testing.expectEqualStrings("one\ntwo\n", pane.file.?.content); + } +} + +test "tag prompt capacity refusal leaves the existing tail and input state untouched" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + @memset(&pane.tag_tail, 'x'); + pane.tag_tail_len = pane.tag_tail.len - 1; + pane.tag_init = true; + pane.tag_col = 17; + pane.tag_sel = true; + for (0..3) |case| { + switch (case) { + 0 => p.startPrompt(pane, .{ .search = config.search_marker }), + 1 => p.startPrompt(pane, .save), + else => p.update(.{ .key = .{ .cp = '|' } }), + } + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit); + try std.testing.expect(pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + try std.testing.expectEqual(pane.tag_tail.len - 1, pane.tag_tail_len); + for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte); + } +} + +test "generated output refuses every pipe prompt before seeding the tag" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const content = try p.gpa.dupe(u8, "generated\n"); + const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content); + p.active = 1; + for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| { + p.update(.{ .key = key }); + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_init); + try std.testing.expect(!pane.tag_edit); + try std.testing.expectEqualStrings("generated\n", pane.file.?.content); + } +} + +test "tag prompt owns the pipe behavior through cancellation and submission" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + for (std.enums.values(modal.Normal.PipeBehavior)) |how| { + p.startPrompt(pane, .{ .pipe = .append }); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.startPrompt(pane, .{ .pipe = how }); + try std.testing.expectEqual(how, pane.prompt.pipe.how); + p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const pending = p.pipe_wait orelse return error.MissingPipe; + try std.testing.expectEqual(how, pending.how); + try std.testing.expectEqualStrings("cat", pending.command); + try std.testing.expectEqual(@as(usize, 1), pending.inputs.len); + try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes); + try std.testing.expect(pane.prompt == .none); + } +} + +test "tag prompt cancellation restores the selection before regex previews" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one two one"); + const cases = [_]struct { key: u21, pattern: []const u8, secondary: u8 }{ + .{ .key = 's', .pattern = "one", .secondary = 1 }, + .{ .key = 'S', .pattern = " ", .secondary = 2 }, + }; + for (cases) |case| { + pane.cur_row = 0; + pane.cur_col = 10; + pane.cur_pinned = true; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + pane.nsel = 0; + p.update(.{ .key = .{ .cp = case.key } }); + try std.testing.expect(pane.prompt == .search); + p.update(.{ .key = .{ .cp = case.pattern[0], .text = case.pattern } }); + try std.testing.expectEqual(case.secondary, pane.nsel); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(u8, 0), pane.nsel); + try std.testing.expectEqual(@as(u8, 0), pane.nsel_snap); + try std.testing.expectEqual(@as(i32, 10), pane.cur_col); + try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); + try std.testing.expect(pane.vsel.active and pane.vsel.explicit); + try std.testing.expectEqualStrings("one two one", pane.file.?.content); + } +} + +test "tag prompt prefix allocation failure preserves an already seeded prompt" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + const tail = " keep /prior"; + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; + pane.tag_init = true; + pane.prompt = .{ .search = 5 }; + pane.tag_edit = true; + pane.tag_sel = true; + pane.tag_col = 14; + pane.tag_anchor = 7; + pane.normal = .{ .count = 9, .prefix = .goto }; + const previous_prompt = pane.prompt; + const previous_normal = pane.normal; + _ = p.scratch.reset(.free_all); + failing.fail_index = failing.alloc_index; + p.startPrompt(pane, request); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqualDeep(previous_prompt, pane.prompt); + try std.testing.expectEqualDeep(previous_normal, pane.normal); + try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]); + try std.testing.expect(pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqual(@as(u16, 14), pane.tag_col); + try std.testing.expectEqual(@as(u16, 7), pane.tag_anchor); + } +} + +test "tag prompt Save requires room for its complete directory prefix" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + for ([_][]const u8{ "/dír space", "/dír space/", "" }) |dir| { + try pane.setOwnedCwd(dir); + const slash: usize = @intFromBool(dir.len == 0 or dir[dir.len - 1] != '/'); + const needed = config.save_marker.len + dir.len + slash; + for ([_]usize{ 1, 0 }) |shortfall| { + @memset(&pane.tag_tail, 'x'); + pane.tag_tail_len = pane.tag_tail.len - needed + shortfall; + pane.tag_init = true; + pane.tag_edit = false; + pane.tag_sel = true; + pane.tag_col = 17; + const start = pane.tag_tail_len; + p.startPrompt(pane, .save); + if (shortfall != 0) { + try std.testing.expect(pane.prompt == .none); + try std.testing.expectEqual(start, pane.tag_tail_len); + try std.testing.expect(!pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte); + } else { + try std.testing.expect(pane.prompt == .save); + try std.testing.expectEqual(pane.tag_tail.len, pane.tag_tail_len); + try std.testing.expectEqualStrings(config.save_marker, pane.tag_tail[start..][0..config.save_marker.len]); + try std.testing.expectEqualStrings(dir, pane.tag_tail[start + config.save_marker.len ..][0..dir.len]); + try std.testing.expectEqual(@as(u8, '/'), pane.tag_tail[pane.tag_tail_len - 1]); + p.update(.{ .key = .{ .cp = Key.escape } }); + } + } + } +} + +test "tag prompt successful seeded entry needs one scratch allocation" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + pane.tag_tail_len = 0; + pane.tag_init = true; + _ = p.scratch.reset(.free_all); + const before = failing.alloc_index; + p.startPrompt(pane, request); + try std.testing.expectEqual(before + 1, failing.alloc_index); + try std.testing.expect(pane.tag_edit and pane.mode == .insert); + } +} + +test "tag prompt failure may initialize only the default tail" { + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { + var saw_seeded_failure = false; + var succeeded = false; + for (0..16) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\n"); + const path = try p.gpa.dupe(u8, "/" ++ "a" ** 2048 ++ ".zig"); + p.gpa.free(pane.file.?.path); + pane.file.?.path = path; + p.seedTail(pane); + try std.testing.expect(pane.tag_init); + const default_tail = pane.tag_tail; + const default_len = pane.tag_tail_len; + pane.tag_init = false; + pane.tag_tail_len = 0; + pane.tag_col = 17; + pane.tag_sel = true; + pane.normal = .{ .count = 9, .prefix = .goto }; + const previous_normal = pane.normal; + _ = p.scratch.reset(.free_all); + failing.fail_index = failing.alloc_index + failure; + p.startPrompt(pane, request); + if (!failing.has_induced_failure) { + try std.testing.expect(pane.tag_edit and pane.mode == .insert); + succeeded = true; + break; + } + try std.testing.expect(pane.prompt == .none); + try std.testing.expect(!pane.tag_edit and pane.tag_sel); + try std.testing.expectEqual(.normal, pane.mode); + try std.testing.expectEqualDeep(previous_normal, pane.normal); + try std.testing.expectEqual(@as(u16, 17), pane.tag_col); + if (pane.tag_init) { + saw_seeded_failure = true; + try std.testing.expectEqualStrings(default_tail[0..default_len], pane.tag_tail[0..pane.tag_tail_len]); + } else try std.testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + } + try std.testing.expect(succeeded and saw_seeded_failure); + } +} + +test "file history construction failures preserve existing panes" { + const Case = enum { open, restore, output, tutor, replace }; + for (std.enums.values(Case)) |case| { + var succeeded = false; + for (0..32) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = try p.setTestFile("original\n"); + const old_history = original.file.?.history; + const serial = p.next_serial; + var path_buffer: [128]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{original.serial}); + var incoming: ?[]u8 = if (case == .output) try p.gpa.dupe(u8, "generated\n") else null; + defer if (incoming) |bytes| p.gpa.free(bytes); + failing.fail_index = failing.alloc_index + failure; + const result = switch (case) { + .open => panes.File.open(p, 1, path, 0), + .restore => panes.File.restore(p, 1, .{ + .kind = .file, + .tag = "", + .body = "", + .file = .{ .path = "/restored.zig", .content = "restored\n" }, + }), + .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?), + .tutor => p.openTutorView(1), + .replace => p.setTestFile("replacement\n"), + }; + if (result) |pane| { + if (case == .output) incoming = null; + try std.testing.expect(pane.file.?.history != old_history); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len); + succeeded = true; + break; + } else |err| { + try std.testing.expect(err == error.OutOfMemory or (case == .open and err == error.WriteFailed and failing.has_induced_failure)); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqual(old_history, original.file.?.history); + try std.testing.expectEqualStrings("original\n", original.file.?.content); + try std.testing.expectEqual(serial, p.next_serial); + try std.testing.expect(p.panes[1] == null); + try std.testing.expect(!p.reserved_slots[1]); + } + } + try std.testing.expect(succeeded); + } +} + +test "file history owns bounded snapshot storage without per-edit bookkeeping allocations" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("initial"); + const file = &pane.file.?; + const history = file.history; + for (0..pardes.memory.limits.undo_max + 2) |index| { + const next = try std.fmt.allocPrint(p.gpa, "{d}", .{index}); + const before = failing.alloc_index; + panes.File.pushUndo(p, pane); + try std.testing.expectEqual(before + 1, failing.alloc_index); + panes.File.setContent(p, file, next); + try std.testing.expectEqual(history, file.history); + } + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqualStrings("1", history.undo[0].content); + const current = file.content; + failing.fail_index = failing.alloc_index; + panes.File.pushUndo(p, pane); + panes.File.undo(p, pane); + try std.testing.expectEqual(current.ptr, file.content.ptr); + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqual(@as(usize, 0), history.redo_len); + failing.fail_index = std.math.maxInt(usize); + const before = failing.alloc_index; + panes.File.undo(p, pane); + try std.testing.expectEqual(before + 2, failing.alloc_index); + try std.testing.expectEqual(@as(usize, 1), file.line_starts.len); + try std.testing.expectEqual(@as(usize, 1), history.redo_len); + const undone = file.content; + failing.fail_index = failing.alloc_index; + panes.File.redo(p, pane); + try std.testing.expectEqual(undone.ptr, file.content.ptr); + try std.testing.expectEqual(@as(usize, 1), history.redo_len); + failing.fail_index = std.math.maxInt(usize); + const before_redo = failing.alloc_index; + panes.File.redo(p, pane); + try std.testing.expectEqual(before_redo + 2, failing.alloc_index); + try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len); + try std.testing.expectEqual(@as(usize, 0), history.redo_len); +} + +test "file history allocation precedes a filesystem read with side effects" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = p.panes[0].?; + const serial = p.next_serial; + failing.fail_index = failing.alloc_index + 2; + try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new/ctl", 0)); + try std.testing.expect(failing.has_induced_failure); + try std.testing.expectEqual(original, p.panes[0].?); + try std.testing.expectEqual(serial, p.next_serial); + for (p.panes[1..], p.reserved_slots[1..]) |pane, reserved| { + try std.testing.expect(pane == null); + try std.testing.expect(!reserved); + } +} + +test "file history survives PDF fallback restoration and allocation failures" { + const saved = + \\.{ + \\ .screen = .{ .cols = 40, .rows = 12 }, + \\ .columns = .{.{ .panes = .{0} }}, + \\ .panes = .{.{ + \\ .kind = .image, .tag = "", .body = "", + \\ .image = .{ .path = "/virtual/missing-history.pdf", .bytes_b64 = "cmF3" }, + \\ }}, + \\} + ; + var succeeded = false; + for (0..256) |failure| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = failure }); + if (Pardes.initFromDump(failing.allocator(), .{ .tty_only = true }, saved)) |p| { + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expectEqualStrings("raw", pane.file.?.content); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len); + if (!failing.has_induced_failure) { + succeeded = true; + break; + } + } else |err| { + try std.testing.expect(failing.has_induced_failure); + try std.testing.expect(err == error.OutOfMemory or err == error.WriteFailed); + } + } + try std.testing.expect(succeeded); +} + +test "Save rearms renamed file watches only after a successful write" { + const Host = struct { + const Result = enum { success, failure, edited, replaced, closed }; + core: *Pardes, + result: Result, + watches: usize = 0, + err: ?anyerror = null, + + fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const h: *@This() = @ptrCast(@alignCast(ctx.?)); + switch (h.result) { + .success => {}, + .failure => h.core.saveFailed(id, "save", error.PermissionDenied), + .edited => h.core.panes[id].?.file.?.revision +%= 1, + .replaced => { + const replacement = h.core.setTestFile("replacement\n") catch |err| { + h.err = err; + return; + }; + replacement.file.?.watch_after_save = true; + replacement.file.?.saved_revision -%= 1; + }, + .closed => { + h.core.deinitPane(h.core.panes[id].?) catch |err| { + h.err = err; + return; + }; + h.core.panes[id] = null; + }, + } + } + + fn watch(ctx: ?*anyopaque, _: u8, _: []const u8, on: bool, _: pardes.WatchMode) void { + const h: *@This() = @ptrCast(@alignCast(ctx.?)); + if (on) h.watches += 1; + } + }; + const Case = struct { result: Host.Result, path: []const u8 = "/renamed.zig", watches: usize = 0 }; + for ([_]Case{ + .{ .result = .success, .watches = 1 }, + .{ .result = .success, .path = "/n/os/renamed.zig", .watches = 1 }, + .{ .result = .success, .path = "/virtual/pane/0/body" }, + .{ .result = .success, .path = "/n/remote/file.zig" }, + .{ .result = .failure }, + .{ .result = .edited }, + .{ .result = .replaced }, + .{ .result = .closed }, + }) |case| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const original = try p.setTestFile("edited\n"); + p.gpa.free(original.file.?.path); + original.file.?.path = try p.gpa.dupe(u8, case.path); + original.file.?.saved_revision -%= 1; + original.file.?.watch_after_save = true; + while (p.nextEffect()) |_| {} + var host: Host = .{ .core = p, .result = case.result }; + p.host = .{ .ctx = &host, .vtable = &.{ .write_file = Host.write, .watch_file = Host.watch } }; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + if (host.err) |err| return err; + try std.testing.expectEqual(case.watches, host.watches); + if (p.panes[0]) |pane| { + const file = &pane.file.?; + try std.testing.expectEqual(case.result != .success, file.watch_after_save); + try std.testing.expectEqual(case.result == .success, file.revision == file.saved_revision); + } else try std.testing.expectEqual(.closed, case.result); + if (case.result == .failure) { + host.result = .success; + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expectEqual(@as(usize, 1), host.watches); + try std.testing.expect(!original.file.?.watch_after_save); + try std.testing.expectEqual(original.file.?.revision, original.file.?.saved_revision); + } + } +} + +test "Save on a scratch does not watch a path the host could not create" { + const Refusing = struct { + fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void { + const p: *Pardes = @ptrCast(@alignCast(ctx.?)); + p.saveFailed(id, "save", error.PermissionDenied); + } + }; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(0, "New")); + const id = p.active; + while (p.nextEffect()) |_| {} + p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.write } }; + try std.testing.expect(p.executeBuiltinLine(id, "Save /new-file.txt")); + while (p.nextEffect()) |effect| p.perform(effect); + const file = &p.panes[id].?.file.?; + try std.testing.expect(file.output == null); + try std.testing.expect(file.watch_after_save); + try std.testing.expect(file.revision != file.saved_revision); + try std.testing.expect(!p.fallback.watched[id]); + p.host = .{}; + try std.testing.expect(p.executeBuiltinLine(id, "Save")); + while (p.nextEffect()) |effect| p.perform(effect); + try std.testing.expect(!file.watch_after_save); + try std.testing.expectEqual(file.revision, file.saved_revision); + try std.testing.expect(p.fallback.watched[id]); +} + +test "unplaced terminal panes keep their allocated grid until placement" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + while (p.nextEffect()) |_| {} + const id = p.freeSlot().?; + const pane = try p.newShell(id, ""); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "unplaced\r\n" } }); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + try std.testing.expectEqual(@as(u16, 100), pane.cols); + try std.testing.expectEqual(@as(u16, 30), pane.rows); + try std.testing.expectEqual(pane.cols, pane.terminal.?.vt.cols); + try std.testing.expectEqual(pane.rows, pane.terminal.?.vt.rows); + while (p.nextEffect()) |effect| { + if (effect == .resize_pty and effect.resize_pty.pane == id) return error.ResizedUnplacedPane; + } + pardes.layout.insert(p, 0, 1, id); + p.update(.tick); + try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > pardes.BOX_H); + try std.testing.expectEqual(p.rects[id].w - config.GUTTER, pane.cols); + try std.testing.expectEqual(p.rects[id].h - pardes.BOX_H, pane.rows); +} + +test "document panes ignore terminal output without allocating terminal state" { + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("document body\n"); + while (p.nextEffect()) |_| {} + const revision = pane.file.?.revision; + const allocations = allocator.alloc_index; + const resizes = allocator.resize_index; + allocator.fail_index = allocations; + allocator.resize_fail_index = resizes; + p.update(.{ .output = .{ .pane = 0, .bytes = "unrelated PTY output\r\n" } }); + try std.testing.expect(!allocator.has_induced_failure); + try std.testing.expectEqual(allocations, allocator.alloc_index); + try std.testing.expectEqual(resizes, allocator.resize_index); + try std.testing.expect(pane.terminal == null); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqualStrings("document body\n", pane.file.?.content); +} + +test "removed and recycled pane slots have no stale geometry" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + const id = p.freeSlot().?; + const original = try p.newDocPane(id); + pardes.layout.insert(p, 0, 1, id); + layout.compute(p); + try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > 0); + layout.removePane(p, id); + layout.compute(p); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + + try p.deinitPane(original); + p.panes[id] = null; + p.rects[id] = .{ .x = 10, .y = 5, .w = 20, .h = 10 }; + const replacement = try p.newDocPane(id); + try std.testing.expect(replacement.serial != original.serial); + try std.testing.expectEqual(pardes.Rect{}, p.rects[id]); + pardes.layout.insert(p, 0, 1, id); + + for ([_]u16{ 1, 2, 3, 10 }) |size| { + p.update(.{ .resize = .{ .cols = size, .rows = size } }); + for (p.rects) |rect| { + try std.testing.expect(rect.x + rect.w <= size); + try std.testing.expect(rect.y + rect.h <= size); + } + } + p.ncol = 0; + layout.compute(p); + for (p.rects) |rect| try std.testing.expectEqual(pardes.Rect{}, rect); +} + +const FileTests = struct { + const graphemeDisplayWidth = panes.File.graphemeDisplayWidth; + const displayWidth = panes.File.displayWidth; + const byteAtDisplay = panes.File.byteAtDisplay; + const rawDisplayCol = panes.File.rawDisplayCol; + const rawAtDisplay = panes.File.rawAtDisplay; + const VisualRow = panes.File.VisualRow; + const visualRow = panes.File.visualRow; + + test "display columns map complete Unicode graphemes" { + const text = "é界e\u{301}x"; + try std.testing.expectEqual(@as(usize, 5), displayWidth(text)); + try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2)); + try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4)); + try std.testing.expectEqual(text.len, byteAtDisplay(text, 5)); + try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5)); + try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀")); + } + + test "visual rows partition a line at the breaks the body renders" { + const line = "abcdefgh"; + try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3)); + try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3)); + // Past the end (a cursor on the newline) names the LAST row, and a short + // line is one row however narrow the pane is. + try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3)); + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3)); + // A grapheme wider than the row still occupies exactly one row. + try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1)); + } +}; + +const OutputTests = struct { + const Origin = panes.Output.Origin; + const max_arg = panes.Output.max_arg; + const State = panes.Output.State; + const setArg = panes.Output.setArg; + const fromWord = panes.Output.fromWord; + const fillResults = panes.Output.fillResults; + + test "replacing generated Help clears stale selection before executing a new row" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + p.presentation.enabled = false; + try std.testing.expect(p.executeBuiltinLine(0, "Help")); + const id = p.active; + const help = p.panes[id].?; + for ("gevkk") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expect(help.vsel.active); + try std.testing.expect(help.vsel.row > 5); + try std.testing.expect(help.cur_row != help.vsel.row); + for (" h?") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expectEqual(id, p.active); + const content = help.file.?.content; + try std.testing.expect(std.mem.startsWith(u8, content, "pardes builtins under SPC h\n")); + const at = std.mem.indexOf(u8, content, "Tutor") orelse return error.MissingTutorRow; + const row = std.mem.count(u8, content[0..at], "\n"); + const line_start = if (std.mem.lastIndexOfScalar(u8, content[0..at], '\n')) |nl| nl + 1 else 0; + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(help) + @as(u16, @intCast(at - line_start + 2)); + const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(row)); + p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = x, .row = y } }); + try std.testing.expect(p.active != id); + const tutor = p.panes[p.active].?.file orelse return error.MissingTutor; + try std.testing.expect(std.mem.indexOf(u8, tutor.content, "PARDES TUTOR") != null); + } + + test "generated result refills preserve unchanged selections and reset changed text" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + const content = "first\nsecond\nthird\n"; + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null); + const id = for (p.panes, 0..) |slot, id| { + if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id; + } else return error.MissingResults; + const result = p.panes[id].?; + p.active = id; + for ("Cv3") |key| p.update(.{ .key = .{ .cp = key } }); + try std.testing.expect(result.nsel > 0); + try std.testing.expect(result.select); + try std.testing.expect(result.vsel.active); + try std.testing.expect(result.normal.count > 0); + result.file.?.scroll = 1; + result.hscroll = 2; + const selected = result.vsel; + const selections = result.nsel; + const cursor_row = result.cur_row; + const cursor_col = result.cur_col; + + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null); + try std.testing.expectEqual(selected, result.vsel); + try std.testing.expectEqual(selections, result.nsel); + try std.testing.expectEqual(cursor_row, result.cur_row); + try std.testing.expectEqual(cursor_col, result.cur_col); + try std.testing.expectEqual(@as(usize, 1), result.file.?.scroll); + try std.testing.expectEqual(@as(i32, 2), result.hscroll); + try std.testing.expect(result.normal.count > 0); + + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expectEqualStrings("short\n", result.file.?.content); + try std.testing.expect(!result.vsel.active and !result.msel.active and !result.select); + try std.testing.expectEqual(@as(u8, 0), result.nsel); + try std.testing.expectEqual(@as(i32, 0), result.cur_row); + try std.testing.expectEqual(@as(i32, 0), result.cur_col); + try std.testing.expectEqual(@as(usize, 0), result.file.?.scroll); + try std.testing.expectEqual(@as(i32, 0), result.hscroll); + try std.testing.expectEqual(modal.Normal.State{}, result.normal); + } + + test "generated result replacement cancels an in-progress body selection" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("notes\n"); + p.presentation.enabled = false; + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "first\nsecond\nthird\n"), null); + const id = for (p.panes, 0..) |slot, id| { + if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id; + } else return error.MissingResults; + const result = p.panes[id].?; + p.update(.tick); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(&.{}); + const rect = p.rects[id]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(result); + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y + 1 } }); + try std.testing.expect(p.drag == .select); + try std.testing.expectEqual(.dragging, result.sel[@intFromEnum(config.select_button)].state); + try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); + try std.testing.expect(p.drag == .none); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y + 1 } }); + for (result.sel) |selection| try std.testing.expectEqual(.none, selection.state); + try std.testing.expect(!result.vsel.active and !result.msel.active); + } + + test "dump origins accept only builtins that actually own output panes" { + try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?); + try std.testing.expect(fromWord("Kill") == null); + try std.testing.expect(fromWord("Theme") == null); + } + + test "result refill identity retains the full bounded argument" { + const p = try Pardes.init(std.testing.allocator, .{ + .tty_only = true, + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + + var first: [max_arg]u8 = @splat('a'); + var second = first; + first[200] = 'x'; + second[200] = 'y'; + + try fillResults( + p, + 0, + "/tmp", + .search, + &first, + try p.gpa.dupe(u8, "first\n"), + null, + ); + const first_id = p.panes[0].?.search_pane orelse return error.MissingResults; + const next_slot = p.freeSlot(); + + try fillResults( + p, + 0, + "/tmp", + .search, + &first, + try p.gpa.dupe(u8, "refilled\n"), + null, + ); + try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?); + try std.testing.expectEqual(next_slot, p.freeSlot()); + try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content); + + try fillResults( + p, + 0, + "/tmp", + .search, + &second, + try p.gpa.dupe(u8, "second\n"), + null, + ); + try std.testing.expect(p.panes[0].?.search_pane.? != first_id); + + var output: State = .{ .from = .search }; + var oversized: [max_arg + 1]u8 = @splat('z'); + try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized)); + const slot_before_error = p.freeSlot(); + try std.testing.expectError( + error.ArgumentTooLong, + fillResults( + p, + 0, + "/tmp", + .search, + &oversized, + try p.gpa.dupe(u8, "must be freed\n"), + null, + ), + ); + try std.testing.expectEqual(slot_before_error, p.freeSlot()); + } +}; + +const ImageTests = struct { + const State = panes.Image.State; + const restore = panes.Image.restore; + const toggleGlyphArt = panes.Image.toggleGlyphArt; + const togglePalette = panes.Image.togglePalette; + const toggleAscii = panes.Image.toggleAscii; + const tagPrefix = panes.Image.tagPrefix; + const legacySavedPrefix = panes.Image.legacySavedPrefix; + + test "pane-local renderer choices are visible in the image tag" { + var state: State = .{ .path = @constCast("/tmp/picture.ppm") }; + toggleGlyphArt(&state); + togglePalette(&state); + toggleAscii(&state); + const tag = try tagPrefix(std.testing.allocator, &state); + defer std.testing.allocator.free(tag); + try std.testing.expectEqualStrings( + config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm", + tag, + ); + try std.testing.expectEqualStrings( + "img /tmp/picture.ppm", + legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?, + ); + } + + test "dump restore propagates malformed embedded image bytes" { + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try std.testing.expect(p.panes[1] == null); + try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{ + .kind = .image, + .tag = "img /tmp/bad.ppm", + .body = "", + .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" }, + })); + try std.testing.expect(p.panes[1] == null); + } +}; + +const PdfTests = struct { + const enabled = panes.Pdf.enabled; + const pdf = panes.Pdf.pdf; + const Point = panes.Pdf.Point; + const Document = panes.Pdf.Document; + const isPath = panes.Pdf.isPath; + const legacySavedPrefix = panes.Pdf.legacySavedPrefix; + const State = panes.Pdf.State; + const probeWord = panes.Pdf.probeWord; + const pointAtGeometry = panes.Pdf.pointAtGeometry; + const SectionRows = panes.Pdf.SectionRows; + + test "PDF dump paths are recognized independent of MuPDF support" { + try std.testing.expect(isPath("manual.pdf")); + try std.testing.expect(isPath("MANUAL.PDF")); + try std.testing.expect(!isPath("manual.pdf.txt")); + try std.testing.expect(!isPath("pdf")); + } + + test "legacy PDF prefix is delimited by its stable marker and exact path" { + const path = "/tmp/a document.pdf"; + const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del"; + try std.testing.expectEqualStrings( + "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path, + legacySavedPrefix(path, tag).?, + ); + } + + test "feature-off PDF state is zero-sized" { + if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State)); + } + + test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { + if (!enabled) return; + const entries = [_]pdf.OutlineEntry{ + .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } }, + .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } }, + .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } }, + .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } }, + .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } }, + }; + const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries); + defer std.testing.allocator.free(rows); + try std.testing.expectEqualStrings( + "manual.pdf:3:1 [untitled]\n" ++ + "manual.pdf:3:2 Child Title Café Next\n" ++ + "manual.pdf:1:3 [empty title]\n" ++ + "https://example.com/manual External\n" ++ + "https://example.com/deep Linked branch\n" ++ + "https://example.com/deep Deep link\n", + rows, + ); + try std.testing.expect(SectionRows.resolve(&entries, 4) == null); + try std.testing.expect(SectionRows.resolve(&entries, 5) == null); + const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); + defer std.testing.allocator.free(resolved); + for (entries, 0..) |_, ordinal| { + const single = SectionRows.resolve(&entries, ordinal); + if (resolved[ordinal] == std.math.maxInt(usize)) { + try std.testing.expect(single == null); + } else { + const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse + return error.MissingBulkPdfSectionDestination; + try std.testing.expect(single != null); + try std.testing.expect(std.meta.eql(single.?, bulk)); + } + } + + // Every allocation site in the ordinal table and output growth remains + // atomic: the testing allocator sees each induced failure cleaned up + // before the first index at which the whole render can succeed. + var rendered = false; + for (0..64) |fail_index| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ + .fail_index = fail_index, + }); + const failure_gpa = failing.allocator(); + const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + continue; + }; + failure_gpa.free(attempt); + rendered = true; + break; + } + try std.testing.expect(rendered); + } + + test "bulk PDF section resolution matches single Look policy across DFS boundaries" { + if (!enabled) return; + const entries = [_]pdf.OutlineEntry{ + .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } }, + .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } }, + .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } }, + .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none }, + .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } }, + }; + const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries); + defer std.testing.allocator.free(bulk); + const none = std.math.maxInt(usize); + try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk); + for (entries, 0..) |_, ordinal| { + const single = SectionRows.resolve(&entries, ordinal); + if (bulk[ordinal] == none) { + try std.testing.expect(single == null); + } else { + const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse + return error.MissingBoundaryPdfSectionDestination; + try std.testing.expect(single != null); + try std.testing.expect(std.meta.eql(single.?, destination)); + } + } + } + + test "PDF word probe owns quads and text without a selection object" { + if (comptime !enabled) return; + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + var found = try document.search(std.testing.allocator, 0, "Pardes"); + defer found.deinit(std.testing.allocator); + const quad = found.quads[0].quad; + const point = Point{ + .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4, + .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4, + }; + + // Exactly one shared probe exercises the primitive used by both UI paths; + // click-vs-hover equivalence belongs to production structure, not a second + // test that can only restate this MuPDF result. + var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse + return error.MissingPdfWordProbe; + defer probe.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), probe.page); + try std.testing.expect(probe.quads.len > 0); + try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null); + } + + test "PDF point mapping restores band origin and fractional placement" { + if (comptime !enabled) return; + const point = pointAtGeometry( + .{ + .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 }, + .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 }, + }, + -0.5, + 200, + 200, + 80, + 120, + 34, + false, + ) orelse return error.MissingPdfMappedPoint; + // x: src 30; y: band 80 + local src 14. The returned coordinates address + // pixel centers, matching MuPDF's normalized page-space contract. + try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001); + try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001); + } +}; + +const TerminalTests = struct { + 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 { + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const rows: usize = r.h - pardes.BOX_H; + const cols: usize = r.w -| config.GUTTER; + + const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle }; + const glyphAt = struct { + fn f(sn: Snap, i: usize) []const u8 { + return sn.text[i][0..sn.len[i]]; + } + }.f; + var shot: [2]Snap = undefined; + for (&shot) |*sn| { + sn.text = try gpa.alloc([7]u8, rows * cols); + sn.len = try gpa.alloc(u8, rows * cols); + sn.style = try gpa.alloc(pardes.CellStyle, rows * cols); + } + defer for (&shot) |*sn| { + gpa.free(sn.text); + gpa.free(sn.len); + gpa.free(sn.style); + }; + + for ([_]pardes.Pane.Mode{ .tty, .normal }, 0..) |mode, i| { + pane.mode = mode; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const s = try p.render(frame.allocator()); + for (0..rows) |row| for (0..cols) |col| { + const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + shot[i].text[row * cols + col] = cell.text; + shot[i].len[row * cols + col] = cell.len; + shot[i].style[row * cols + col] = cell.style; + }; + } + + var diffs: usize = 0; + for (0..rows) |row| { + const base = row * cols; + // The glyph run normal mode shows, and where it ends. + var last: ?usize = null; + for (0..cols) |col| { + if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col; + } + const end = last orelse continue; // blank row: nothing to align + + // Recover the shift: the first offset at which tty mode spells the same + // run. Zero for every row no prompt was hugged out of. + const shift = shift: { + var s: usize = 0; + while (s + end < cols) : (s += 1) { + var all = true; + for (0..end + 1) |col| { + if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) { + all = false; + break; + } + } + if (all) break :shift s; + } + var tty_row: [256]u8 = undefined; + var nrm_row: [256]u8 = undefined; + var tn: usize = 0; + var nn: usize = 0; + for (0..cols) |col| { + const tg = glyphAt(shot[0], base + col); + const ng = glyphAt(shot[1], base + col); + if (tn + tg.len < tty_row.len) { + @memcpy(tty_row[tn..][0..tg.len], tg); + tn += tg.len; + } + if (nn + ng.len < nrm_row.len) { + @memcpy(nrm_row[nn..][0..ng.len], ng); + nn += ng.len; + } + } + std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] }); + diffs += 1; + break :shift null; + } orelse continue; + + // Every column the shift can reach, not just the ones holding a glyph: + // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded + // table cell) carries colour too, and is exactly what a shell paints + // most of. + for (0..cols - shift) |col| { + const want = shot[0].style[base + col + shift]; + const got = shot[1].style[base + col]; + if (std.meta.eql(want, got)) continue; + if (diffs < 6) std.debug.print( + "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}", + .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul }, + ); + diffs += 1; + } + } + if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs }); + return diffs; + } + + test "fresh-shell greeting waits for OSC 133 B input phase" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} // initial spawn + + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforeOutput, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); + try std.testing.expect(!promptInputReady(pane)); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.GreetedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + try std.testing.expect(promptInputReady(pane)); + var greeted = false; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), + else => {}, + }; + try std.testing.expect(greeted); + try std.testing.expect(!pane.greet); + } + + test "fresh-shell commands preserve order and wait for OSC 133 B" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet + + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforeFork, + else => {}, + }; + + p.acknowledgeShell(0, "/bin/bash", true); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedBeforePrompt, + else => {}, + }; + p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.CommandEscapedDuringPrompt, + else => {}, + }; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); + var sent: [64]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |write| { + const bytes = write.bytes.slice(); + @memcpy(sent[sent_len..][0..bytes.len], bytes); + sent_len += bytes.len; + }, + else => {}, + }; + try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); + try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); + } + + test "unmarked fresh shells omit the automatic greeting" { + if (pardes.platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{}); + defer p.deinit(); + const pane = p.panes[0].?; + while (p.nextEffect()) |_| {} + try std.testing.expect(pane.greet); + + p.acknowledgeShell(0, "/bin/sh", false); + try std.testing.expect(!pane.greet); + try std.testing.expectEqual(.none, pane.pending_command.wait); + p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); + while (p.nextEffect()) |effect| switch (effect) { + .write => return error.UnmarkedGreetingEscaped, + else => {}, + }; + } + + test "raw terminal keys encode text controls and special sequences" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + const Case = struct { key: Key, expected: ?[]const u8 }; + const cases = [_]Case{ + .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, + .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, + .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, + .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, + .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, + .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, + .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, + .{ .key = .{ .cp = Key.home }, .expected = null }, + }; + for (cases) |case| { + forwardKey(p, 0, case.key); + const expected = case.expected orelse { + try std.testing.expect(p.nextEffect() == null); + continue; + }; + const effect = p.nextEffect() orelse return error.MissingWriteEffect; + switch (effect) { + .write => |write| { + try std.testing.expectEqual(@as(u8, 0), write.pane); + try std.testing.expectEqualStrings(expected, write.bytes.slice()); + }, + else => return error.UnexpectedEffect, + } + try std.testing.expect(p.nextEffect() == null); + } + } + + test "an edit buffer slides shell rows and their colors together" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const green: pardes.Color = .{ .index = 2 }; + const blue: pardes.Color = .{ .index = 4 }; + + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + try testing.expectEqual(red, before.at(tx, body_y).style.fg); + try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg); + try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); + + // Four lines of typed text standing in for the ONE shell row `AAA` was: + // every row below slides down by three, and `surfRow` is the arithmetic + // that says so. The colours have to take the same three rows, or `BBB` + // would be painted green three rows above where it is now drawn. + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + + try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme()); + try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme()); + try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg); + try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg); + + // ...and the rows the user typed are the user's own text: no shell row + // sits under them, so nothing projects a colour onto them. + for (0..4) |i| { + const cell = after.at(tx, body_y + @as(u16, @intCast(i))); + try testing.expect(!std.meta.eql(red, cell.style.fg)); + try testing.expect(!std.meta.eql(green, cell.style.fg)); + try testing.expect(!std.meta.eql(blue, cell.style.fg)); + } + } + + test "a combining mark in the prompt keeps the command and its colors aligned" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC` + // typed at it. The cell walk that finds the prompt's end must step ONE + // grapheme for that cell, not one per stored codepoint: stepping twice ate + // the `A`, and left every colour a cell to the left of its glyph with the + // last one stranded on a blank. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme()); + try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme()); + try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg); + try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg); + // ...and no colour past the end of what the row actually says + try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg)); + } + + test "colors are never taken from shell rows below the viewport" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + // Raw palette, so a leaked background reads back as `.index` — the theme + // filter would repaint every blank cell and hide the evidence. + pane.tty_filter = false; + pane.mode = .normal; + + // Sixty rows, each a distinct background, so a leaked colour names the row + // it leaked from. + for (0..60) |i| { + var buf: [32]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + scrollGrid(pane, -20); + + // ONE buffer line standing in for SIX shell rows: everything below slides + // UP five, so the last rows of the body resolve past the viewport's bottom + // edge. `PageList.pin` answers for those rows anyway — it walks down the + // pagelist, not the viewport — so without a bound of its own this pass + // painted the scrollback's colours onto rows the text pass left blank. + const anchor = gridOffset(pane); + pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_h = r.h - pardes.BOX_H; + + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + // A body row the text pass left blank has no shell row under it, so no + // ANSI background may have reached it. Every colour in the payload above is + // an indexed one, so a leak is exactly an `.index` background on a blank row. + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + var blank = true; + var c: u16 = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false; + } + if (!blank) continue; + c = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + const bg = s.at(tx + c, body_y + vr).style.bg; + try testing.expect(std.meta.activeTag(bg) != .index); + } + } + } + + test "a row the edit buffer only swallowed keeps its color" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + const green: pardes.Color = .{ .index = 2 }; + const blue: pardes.Color = .{ .index = 4 }; + + // The buffer only ever grows, so after a few edits it covers rows nobody + // touched. Here it spans all three and only the MIDDLE line differs: the + // first and last are still byte-identical to the shell rows they were + // seeded from, so they still stand over them and keep their colours. + pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme()); + try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme()); + try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(red, s.at(tx, body_y).style.fg); + try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg); + // ...and the line that actually changed is the user's own text now + try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg)); + } + + test "an edit buffer reaching past the dumped rows colors nothing from row zero" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const red: pardes.Color = .{ .index = 1 }; + + // Covers far more rows than the grid was ever dumped for, so the anchor + // table cannot be built and answers "no shell row" for every line. The + // zeroed table must not read as "the last line sits on the buffer's first + // row", which claimed row zero's colour and underflowed on every line after. + pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme()); + var i: u16 = 1; + while (i <= 3) : (i += 1) { + try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg)); + } + } + + test "a prompted session keeps every glyph's color in normal mode" { + const testing = std.testing; + const payload = + "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++ + "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++ + "\x1b[31merror: nope\x1b[0m\r\n" ++ + "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++ + "\x1b[38;5;208mhi\x1b[0m\r\n"; + + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); + const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off"); + try testing.expectEqual(@as(usize, 0), diffs); + } + } + + test "an emoji prompt neither eats the command nor slides its colors" { + const testing = std.testing; + // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this + // surface can BOTH be wrong about a cluster's width, and then a differential + // agrees with itself while the user sees the wrong thing. What is typed at + // the prompt is what must appear, each character wearing its own colour. + // + // Ghostty splits these clusters across cells and spells each one in the row + // dump, so the cell walk and the byte walk only agree if the byte walk is + // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧` + // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here. + const prompts = [_][]const u8{ + "plain", + "\u{1F468}\u{200D}\u{1F4BB}", // technologist + "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family + "\u{1F1FA}\u{1F1F8}", // flag + "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone + "\u{2764}\u{FE0F}", // heart, VS16 + "\u{0031}\u{FE0F}\u{20E3}", // keycap + "\u{754C}", // CJK wide + "e\u{301}", // NFD + }; + for (prompts) |prompt| { + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = .normal; + + var buf: [256]u8 = undefined; + const bytes = try std.fmt.bufPrint( + &buf, + "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m", + .{prompt}, + ); + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| { + const cell = s.at(tx + @as(u16, @intCast(i)), body_y); + testing.expectEqualStrings(want, cell.grapheme()) catch |err| { + std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i }); + return err; + }; + } + // `ab` was printed red and `cd` blue, so whatever the theme does + // with those two runs, the pair boundary has to fall between `b` + // and `c`. A prompt that cost the row a character shows up here as + // the boundary sliding onto the wrong glyph. + const fg = [_]pardes.Color{ + s.at(tx, body_y).style.fg, + s.at(tx + 1, body_y).style.fg, + s.at(tx + 2, body_y).style.fg, + s.at(tx + 3, body_y).style.fg, + }; + errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg }); + try testing.expect(std.meta.eql(fg[0], fg[1])); + try testing.expect(std.meta.eql(fg[2], fg[3])); + try testing.expect(!std.meta.eql(fg[1], fg[2])); + if (!filter) { + try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]); + try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]); + } + } + } + } + + test "background-only cells keep their color through the prompt hug" { + const testing = std.testing; + // Backgrounds with no glyph under them are most of what a shell paints: + // erase-to-end-of-line after a colour is set, padded table cells, and a + // selected row. They have no text to align on, so they are the cells a + // column translation is most likely to lose. + const payload = + "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++ + "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++ + "a\x1b[42m \x1b[0mb\r\n" ++ + "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++ + "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++ + "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n"; + + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + p.update(.{ .output = .{ .pane = 0, .bytes = payload } }); + const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off"); + try testing.expectEqual(@as(usize, 0), diffs); + } + } + + test "a leftover edit buffer does not move what tty mode shows" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + + for (0..60) |i| { + var buf: [40]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const rows: usize = r.h - pardes.BOX_H; + const cols: usize = r.w -| config.GUTTER; + + // What tty mode shows with nothing left behind: the reference. + p.shell_rows.stale = true; + const clean = try p.render(frame.allocator()); + const want_text = try testing.allocator.alloc([7]u8, rows * cols); + defer testing.allocator.free(want_text); + const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols); + defer testing.allocator.free(want_fg); + for (0..rows) |row| for (0..cols) |col| { + const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + want_text[row * cols + col] = cell.text; + want_fg[row * cols + col] = cell.style.fg; + }; + + // `enterTty` clears every other modal remnant but leaves the edit buffer, so + // a buffer whose covered span STRADDLES the viewport top is an ordinary + // state. tty mode does not apply the buffer, so it must not be moved by one + // either — and `surfRow`/`gridRow` are not inverses across that span. + const anchor = gridOffset(pane); + pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + + for (0..rows) |row| for (0..cols) |col| { + const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row))); + try testing.expectEqualStrings( + std.mem.sliceTo(&want_text[row * cols + col], 0), + std.mem.sliceTo(&cell.text, 0), + ); + try testing.expectEqual(want_fg[row * cols + col], cell.style.fg); + }; + } + + test "a background after a row-final wide glyph lands on the right columns" { + const testing = std.testing; + // A CJK glyph then a coloured erase-to-end-of-line, with a second colour + // partway. The glyph's grid tail spells no bytes, so the pairing walk used + // to stop ON it and pair every later column with the cell before it: an + // unpainted hole beside the glyph and every boundary one column right. + // + // ABSOLUTE assertions: both modes were wrong identically here, so a + // tty/normal differential says nothing. + for ([_]bool{ false, true }) |filter| { + for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = mode; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme()); + // The glyph covers columns 0-1; red runs from 2 up to the second + // erase at column 6 (1-based 7), blue from there to the edge. + const red = s.at(tx + 3, body_y).style.bg; + const blue = s.at(tx + 9, body_y).style.bg; + errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg }); + try testing.expect(!std.meta.eql(red, blue)); + for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); + for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg); + } + } + } + + test "a colored row reaches its last column when a wide glyph did not fit" { + const testing = std.testing; + // Thirteen cells of red background, then a wide glyph with one column left: + // ghostty leaves a `spacer_head` in that last column, carrying the row's + // background, and wraps the glyph to the next row. A head OWNS its column, + // so skipping it the way a tail is skipped left the row's final column bare. + for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = mode; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + const red: pardes.Color = .{ .index = 1 }; + var c: u16 = 0; + while (c < r.w -| config.GUTTER) : (c += 1) { + errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg }); + try testing.expectEqual(red, s.at(tx + c, body_y).style.bg); + } + } + } + + test "tty colours survive a scrollback deeper than the pane" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + for (0..40) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const s = try p.render(frame.allocator()); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + var bad: usize = 0; + for (0..r.h -| pardes.BOX_H) |vr| { + var buf: [16]u8 = undefined; + var n: usize = 0; + for (0..10) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + if (g.len != 1) break; + buf[n] = g[0]; + n += 1; + } + const txt = buf[0..n]; + if (!std.mem.startsWith(u8, txt, "line-")) continue; + const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue; + const want = pardes.Color{ .index = @intCast(20 + num) }; + const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; + if (!std.meta.eql(want, got)) { + bad += 1; + std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got }); + } + } + try testing.expectEqual(@as(usize, 0), bad); + } + + test "reverse video swaps the default colors with the filter off too" { + const testing = std.testing; + // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to + // be honoured on BOTH colour paths. The theme filter folds it into its own + // palette; the raw path resolves a `.none` colour by role, and simply + // dropped reverse video altogether. + for ([_]bool{ false, true }) |filter| { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = filter; + pane.mode = .normal; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } }); + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + const plain = before.at(tx, body_y).style; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } }); + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + const reversed = after.at(tx, body_y).style; + + errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg }); + try testing.expectEqual(plain.fg, reversed.bg); + try testing.expectEqual(plain.bg, reversed.fg); + } + } + + test "untouched lines between two edits keep their colors" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + for (0..6) |i| { + var buf: [40]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + // The state two ordinary edits reach: one at the bottom, one that split a + // line further up. The buffer now spans rows 2..6 and diverges at BOTH + // ends, with three untouched lines in the middle. Matching a leading and a + // trailing run stops at the first divergence and drains exactly those three; + // each line carries its own evidence, so each is anchored on its own. + pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05` + for (0..3) |k| { + const vr = @as(u16, @intCast(4 + k)); + var buf: [8]u8 = undefined; + const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable; + const cell = s.at(tx, body_y + vr); + errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg }); + try testing.expectEqualStrings(want_text[0..1], cell.grapheme()); + try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg); + } + } + + test "an emptied edit buffer does not shift the colors below it" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + // The state three keystrokes reach on any blank shell row: type a character + // and delete it, and the buffer holds NO text while still standing in for + // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one + // line, so anything deriving the slide from the former puts every colour + // below here one row too far down — and drops the bottom row's entirely. + pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") }; + p.shell_rows.stale = true; + const s = try p.render(frame.allocator()); + + try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme()); + try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme()); + try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg); + try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg); + // ...and the user's own empty line takes no colour from the row beneath it + try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg)); + } + + test "an edit overlay never changes tty-mode ansi colors" { + const testing = std.testing; + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .tty; + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const blue: pardes.Color = .{ .index = 4 }; + + p.shell_rows.stale = true; + const before = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); + + // A lingering multi-line edit overlay must not move any shell row's colour. + pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; + p.shell_rows.stale = true; + _ = frame.reset(.retain_capacity); + const after = try p.render(frame.allocator()); + try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); + try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); + } + + test "an edited row keeps the colours of the bytes the edit did not touch" { + const testing = std.testing; + // The loudest colour bug this editor had: one keystroke anywhere in a + // coloured row turned EVERY column of it grey, because an anchor was all or + // nothing. The row's own bytes survive at both ends of what was typed, and + // being the same bytes they keep the same colours; only the typed character + // has no cell under it and so takes none. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..6) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + // One `Z` typed into the middle of row 3's own text. + pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + const want = pardes.Color{ .index = 33 }; + var seen = false; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [15]u8 = undefined; + for (0..15) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue; + seen = true; + for (0..15) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got }); + // Column 9 is the typed `Z`; every other column is row 3's own. + if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got); + } + } + try testing.expect(seen); + } + + test "joining two rows leaves the rows below them their colours" { + const testing = std.testing; + // A join removes a buffer line while the buffer's covered span GROWS, so the + // two counts cancel at `lines == covered`. Anchoring that only counts down + // from the buffer's top and up from its bottom then resolves both ways to + // the SAME row, one short of where the lines below live, and every untouched + // row under the join went plain. This is the state four keystrokes reach + // (Enter, then a backspace two rows up), taken from the fuzzer that found it. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + pane.ovl = .{ + .row = 23, + .rows = 4, + .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"), + }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + var seen = false; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [12]u8 = undefined; + for (0..12) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue; + seen = true; + // The join is above it and its own text is untouched, so every column + // still carries row 25's own colour. + for (0..12) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got }); + try testing.expectEqual(pardes.Color{ .index = 45 }, got); + } + } + try testing.expect(seen); + } + + test "an untouched row always carries the colour its own text names" { + const testing = std.testing; + // Random editing, absolute oracle: every row's own text names the colour it + // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing + // anything else. This is what found the join above, and the empty line that + // claimed a blank row far below it and took every coloured row in between + // out of reach of the lines that owned them. + var seed: u64 = 0; + while (seed < 40) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); + + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + // The raw palette, so a row's text names its exact colour instead of one + // this test would have to re-derive from the theme. + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_h = r.h -| pardes.BOX_H; + + var step: usize = 0; + while (step < 12) : (step += 1) { + _ = frame.reset(.retain_capacity); + const s = try p.render(frame.allocator()); + for (0..body_h) |vr| { + var buf: [24]u8 = undefined; + for (0..24) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + const txt = std.mem.trimEnd(u8, buf[0..24], " "); + if (txt.len != 12) continue; + if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue; + const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue; + const want = pardes.Color{ .index = @intCast(20 + num) }; + for (0..txt.len) |c| { + const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got }); + try testing.expectEqual(want, got); + } + } + + switch (rand.intRangeAtMost(u8, 0, 10)) { + 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }), + 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }), + 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }), + 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }), + 4 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 5 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.enter } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 6 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.backspace } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 7 => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); + p.update(.{ .key = .{ .cp = 'W', .text = "W" } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }), + 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }), + else => { + p.update(.{ .key = .{ .cp = 'i', .text = "i" } }); + p.update(.{ .key = .{ .cp = pardes.Key.delete } }); + p.update(.{ .key = .{ .cp = pardes.Key.escape } }); + }, + } + while (p.nextEffect()) |_| {} + } + } + } + + test "a new empty line does not take the colours of the rows below it" { + const testing = std.testing; + // Splitting a row makes an EMPTY buffer line, and empty equals every blank + // row in the buffer's span - including the one under the last output. Left + // free to look ahead for a row spelling the same bytes, that line claimed + // the blank row far below and put every coloured row in between out of + // reach of the lines that owned them. Two keystrokes (Home, Enter) got here. + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + pane.mode = .normal; + for (0..26) |i| { + var buf: [64]u8 = undefined; + const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable; + p.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + } + // A newline typed at column 0 of row 24, and `WW` typed on the blank row + // below the output: the span covers rows 24, 25 and that blank row. + pane.ovl = .{ + .row = 24, + .rows = 3, + .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"), + }; + p.shell_rows.stale = true; + + var frame = std.heap.ArenaAllocator.init(testing.allocator); + defer frame.deinit(); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + + const s = try p.render(frame.allocator()); + var seen: usize = 0; + for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| { + var buf: [12]u8 = undefined; + for (0..12) |c| { + const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme(); + buf[c] = if (g.len == 1) g[0] else '?'; + } + if (!std.mem.startsWith(u8, &buf, "row-2")) continue; + const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue; + if (num != 24 and num != 25) continue; + seen += 1; + const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg; + errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got }); + try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got); + } + try testing.expectEqual(@as(usize, 2), seen); + } +}; + +test { + _ = @import("output.zig"); + _ = @import("pdf.zig"); + _ = @import("fs_namespace.zig"); + _ = @import("hxdiff.zig"); + _ = FileTests; + _ = OutputTests; + _ = ImageTests; + _ = PdfTests; + _ = TerminalTests; +} + +test "an empty counted paste leaves no count for the next motion" { + for ([_]bool{ false, true }) |empty_register| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("abcdefgh\n"); + if (empty_register) p.yank = try p.gpa.dupe(u8, ""); + for ("3p") |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(@as(i32, 1), pane.cur_col); + try std.testing.expectEqualStrings("abcdefgh\n", pane.file.?.content); + } +} + +test "cancelled normal gestures leave no count or subprefix after mouse and focus changes" { + const Gesture = enum { body, tag, focus, last }; + for ([_][]const u8{ "3", "3g", "mr(" }) |keys| { + for ([_]Gesture{ .body, .tag, .focus, .last }) |gesture| { + errdefer std.debug.print("\nkeys={s} gesture={s}\n", .{ keys, @tagName(gesture) }); + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 16 }); + defer p.deinit(); + const pane = try p.setTestFile("abcdefghij\n"); + p.settings.colors = false; + p.presentation.enabled = false; + try std.testing.expect(p.executeBuiltinLine(0, "New")); + const other = p.active; + try std.testing.expect(layout.splitColumn(p, other, other, false)); + p.active = 0; + p.update(.tick); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + for (keys) |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal)); + switch (gesture) { + .body, .tag => { + const rect = p.rects[0]; + const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const x = rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3; + const y = if (gesture == .tag) tag_y else body_y; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x, .row = y } }); + if (gesture == .tag) { + try std.testing.expect(pane.tag_edit); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); + } + }, + .focus, .last => { + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(other, p.active); + if (gesture == .last) { + p.update(.{ .key = .{ .cp = Key.escape } }); + } else { + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'h' } }); + } + try std.testing.expectEqual(@as(usize, 0), p.active); + }, + } + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + const col = pane.cur_col; + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(col + 1, pane.cur_col); + try std.testing.expectEqualStrings("abcdefghij\n", pane.file.?.content); + } + } +} + +test "cancelled normal gestures leave no count or subprefix through raw tty mode" { + for ([_][]const u8{ "3", "3g", "mr(" }) |keys| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 12 }); + defer p.deinit(); + const pane = p.panes[0].?; + pane.mode = .normal; + p.update(.{ .output = .{ .pane = 0, .bytes = "abcdefghij\r\n" } }); + for (keys) |cp| p.update(.{ .key = .{ .cp = cp } }); + try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal)); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(pardes.Pane.Mode.tty, pane.mode); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(pardes.Pane.Mode.normal, pane.mode); + try std.testing.expectEqual(modal.Normal.State{}, pane.normal); + } +} + +test "Colors restores syntax after rendering disabled files and disabled edits" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + for ([_]bool{ false, true }) |edit_while_disabled| { + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("pub fn first() void { if (true) {} }\n"); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/colors.zig"); + p.settings.colors = edit_while_disabled; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + if (edit_while_disabled) { + try std.testing.expect(file.highlights.len > 0); + p.update(.{ .command = "Colors" }); + panes.File.setContent(p, file, try gpa.dupe(u8, "pub fn edited() void { if (false) {} }\n")); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + } + try std.testing.expect(!p.settings.colors); + try std.testing.expectEqual(@as(usize, 0), file.highlights.len); + try std.testing.expect(!file.syntax_dirty); + const rect = p.rects[0]; + const cols = pane.cols; + const rows = pane.rows; + p.update(.{ .command = "Colors" }); + try std.testing.expectEqual(rect, p.rects[0]); + try std.testing.expectEqual(cols, pane.cols); + try std.testing.expectEqual(rows, pane.rows); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + try std.testing.expect(p.settings.colors); + try std.testing.expect(file.highlights.len > 0); + try std.testing.expect(!file.syntax_dirty); + var seen: usize = 0; + for (surface.cells[0 .. surface.cells.len - 1], 0..) |*first, i| { + const second = &surface.cells[i + 1]; + if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and + !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue; + seen += 1; + for ([_]*pardes.Cell{ first, second }) |cell| { + try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + try std.testing.expectEqual(@as(usize, 2), seen); + } +} + +test "growing a file pane colors every newly visible Zig keyword" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 8 }); + defer p.deinit(); + const pane = try p.setTestFile("pub fn f() void { if (true) {} }\n" ** 100); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/resize.zig"); + p.settings.colors = true; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const initial_end = file.highlight_start + file.highlights.len; + for ([_]u16{ 50, 12, 70 }) |rows| { + p.update(.{ .resize = .{ .cols = 80, .rows = rows } }); + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + var seen: usize = 0; + for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { + const first = surface.at(@intCast(x), @intCast(y)); + const second = surface.at(@intCast(x + 1), @intCast(y)); + if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and + !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue; + seen += 1; + for ([_]*pardes.Cell{ first, second }) |cell| { + try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + } + try std.testing.expect(seen >= 2 * (rows - 4)); + try std.testing.expect(file.highlight_start + file.highlights.len > initial_end); + try std.testing.expect(!file.syntax_dirty); + } +} + +test "Zig keywords keep every rendered byte colored across line-number widths" { + if (!syntax.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const samples = [_][]const u8{ + "pub fn main() void { if (true) {} }\n", + "\tpub fn main() void { if (true) {} }\n", + "const text = \"λ界\"; pub fn main() void { if (true) {} }\n", + }; + for ([_]usize{ 9999, 10000, 99999, 100000 }) |line| { + for (samples) |sample| { + for ([_]struct { cols: u16, wrap: bool, horizontal: i32 }{ + .{ .cols = 96, .wrap = false, .horizontal = 0 }, + .{ .cols = 96, .wrap = false, .horizontal = 2 }, + .{ .cols = 38, .wrap = true, .horizontal = 0 }, + }) |view| { + const source = try gpa.alloc(u8, line - 1 + sample.len); + defer gpa.free(source); + @memset(source[0 .. line - 1], '\n'); + @memcpy(source[line - 1 ..], sample); + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = view.cols, .rows = 10 }); + defer p.deinit(); + const pane = try p.setTestFile(source); + const file = &pane.file.?; + gpa.free(file.path); + file.path = try gpa.dupe(u8, "/highlight.zig"); + file.scroll = line - 1; + pane.cur_row = @intCast(line - 1); + pane.cur_col = @intCast(std.mem.indexOf(u8, sample, "fn").?); + pane.hscroll = view.horizontal; + p.settings.colors = true; + p.settings.wrap = view.wrap; + p.presentation.enabled = false; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + var seen: usize = 0; + var if_position: ?struct { x: u16, y: u16 } = null; + const rect = p.rects[0]; + const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + for (body_y..body_y + rect.h - pardes.BOX_H) |y| { + for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| { + const first = surface.at(@intCast(x), @intCast(y)); + const second = surface.at(@intCast(x + 1), @intCast(y)); + const keyword = (std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) or + (std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f")); + if (!keyword) continue; + seen += 1; + if (std.mem.eql(u8, first.grapheme(), "f")) { + try std.testing.expectEqual(@as(u16, @intCast(x)), surface.cursor.?.x); + try std.testing.expectEqual(@as(u16, @intCast(y)), surface.cursor.?.y); + } else if_position = .{ .x = @intCast(x), .y = @intCast(y) }; + for ([_]*pardes.Cell{ first, second }) |cell| { + const expected: pardes.Color = .{ .rgb = p.theme().kw }; + if (!std.meta.eql(expected, cell.style.fg)) std.debug.print( + "\nline={d} wrap={} horizontal={d} sample={s} cell={s}\n", + .{ line, view.wrap, view.horizontal, sample, cell.grapheme() }, + ); + try std.testing.expectEqual(expected, cell.style.fg); + try std.testing.expect(cell.style.bold); + } + } + } + try std.testing.expectEqual(@as(usize, 2), seen); + const clicked = if_position.?; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = clicked.x, .row = clicked.y } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = clicked.x, .row = clicked.y } }); + try std.testing.expectEqual(@as(i32, @intCast(line - 1)), pane.cur_row); + try std.testing.expectEqual(@as(i32, @intCast(std.mem.indexOf(u8, sample, "if").?)), pane.cur_col); + } + } + } +} + +test "terminal overlays preserve trailing blank styles without coloring inserted rows" { + 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(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + pane.tty_filter = false; + p.update(.{ .output = .{ .pane = 0, .bytes = "A\r\n\x1b[41m\x1b[2K\r\n\x1b[42m\x1b[2K\r\n\x1b[44m\x1b[2K\x1b[0m" } }); + pane.mode = .normal; + pane.ovl = .{ .row = 0, .rows = 3, .text = try gpa.dupe(u8, "A\n\n") }; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const rect = p.rects[0]; + const x = rect.x + config.GUTTER; + const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; + const colors = [_]pardes.Color{ .{ .index = 1 }, .{ .index = 2 }, .{ .index = 4 } }; + const original = try p.render(frame.allocator()); + for (colors, 0..) |color, row| { + const cell = original.at(x, y + 1 + @as(u16, @intCast(row))); + try std.testing.expectEqualStrings(" ", cell.grapheme()); + try std.testing.expectEqual(color, cell.style.bg); + } + try std.testing.expect(p.shell_rows.pane == null); + + gpa.free(pane.ovl.?.text); + pane.ovl.?.text = try gpa.dupe(u8, "A\n\n\n"); + _ = frame.reset(.retain_capacity); + const extended = try p.render(frame.allocator()); + for (colors[0..2], 0..) |color, row| + try std.testing.expectEqual(color, extended.at(x, y + 1 + @as(u16, @intCast(row))).style.bg); + const inserted = extended.at(x, y + 3); + try std.testing.expectEqualStrings(" ", inserted.grapheme()); + for (colors) |color| try std.testing.expect(!std.meta.eql(color, inserted.style.bg)); + try std.testing.expectEqual(colors[2], extended.at(x, y + 4).style.bg); + try std.testing.expect(p.shell_rows.pane == null); +} diff --git a/test/pdf.zig b/test/pdf.zig new file mode 100644 index 00000000..b2d03bb8 --- /dev/null +++ b/test/pdf.zig @@ -0,0 +1,1867 @@ +const std = @import("std"); +const pardes = @import("pardes"); +const panes = pardes.panes; +const look = pardes.look; +const pdf_impl = panes.Pdf.pdf; +const image = pardes.image; +const config = pardes.config; +const builtins = pardes.builtins; +const layout = pardes.layout; +const dump = pardes.dump; + +const Pardes = pardes.Pardes; +const Builtin = builtins.registry.Builtin(); +const Key = pardes.Key; +const Event = pardes.Event; +const Mode = pardes.Pane.Mode; +const Surface = pardes.Surface; +const ImagePlace = pardes.ImagePlace; +const PdfFitMode = panes.Pdf.FitMode; +const PdfTintMode = panes.Pdf.TintMode; +const pdf_enabled = panes.Pdf.enabled; +const platform = pardes.platform; +const themes = pardes.themes; +const pdf_raster_policy = pardes.pdf_raster_policy; +const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX; +const BOX_H = pardes.BOX_H; +const sel_slot = @intFromEnum(config.select_button); +const pane_tail = " " ++ config.pane_builtins_str; + +fn hasPdf(pane: *const pardes.Pane) bool { + return if (comptime pdf_enabled) pane.pdf != null else false; +} + +test "PDF mounted bytes own their source after the caller frees it" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + const source = try pardes.filesystem.readFile(gpa, "docs/design.pdf"); + var document = pdf_impl.Document.openBytes(source) catch |err| { + gpa.free(source); + return err; + }; + @memset(source, 0); + gpa.free(source); + defer document.deinit(); + + var original = try pdf_impl.Document.open("docs/design.pdf"); + defer original.deinit(); + try std.testing.expectEqual(original.pages, document.pages); + try std.testing.expectEqual(try original.pageSize(0), try document.pageSize(0)); + const raster = try document.render(gpa, 0); + defer gpa.free(raster.rgba); + try std.testing.expect(raster.rgba.len > 0); +} + +test "explicit OS PDF opens and reloads keep their namespace path" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const original = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(original); + const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(replacement); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + var directory_buffer: [4096]u8 = undefined; + const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer); + const path = try std.fmt.allocPrint(gpa, "/n/os{s}/live.pdf", .{directory_buffer[0..directory_len]}); + defer gpa.free(path); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expectEqualStrings(path, pane.pdf.?.path); + try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); + var watched = false; + while (p.nextEffect()) |effect| { + if (effect == .watch and effect.watch.pane == 0 and effect.watch.on) watched = true; + } + try std.testing.expect(watched); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqualStrings(path, pane.pdf.?.path); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page_count); +} + +test "PDF feature gates keep argv and builtin behavior coherent" { + const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); + const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint"); + const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections"); + try std.testing.expectEqual(pdf_enabled, maybe_fit != null); + try std.testing.expectEqual(pdf_enabled, maybe_tint != null); + try std.testing.expectEqual(pdf_enabled, maybe_sections != null); + if (pdf_enabled) { + try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?); + try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?); + try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?); + } else if (platform != .web) { + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); + } +} + +test "PDF dump fallback remains a byte-preserving file" { + const gpa = std.testing.allocator; + const path = "/definitely/missing/pardes-dump-fallback.PDF"; + const source = "%PDF embedded fallback bytes\x00\xff"; + const encoded = try dump.encodeBytes(gpa, source); + defer gpa.free(encoded); + const ids = [_]usize{0}; + const columns = [_]dump.Column{.{ .panes = &ids }}; + const saved_panes = [_]dump.Pane{.{ + .kind = .image, + .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del", + .body = "", + .scroll = 2, + .cols = 80, + .rows = 24, + .image = .{ .path = path, .bytes_b64 = encoded }, + }}; + const state = dump.State{ + .screen = .{ .cols = 80, .rows = 24 }, + .columns = &columns, + .panes = &saved_panes, + }; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); + + const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written()); + defer restored.deinit(); + const pane = restored.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); + try std.testing.expectEqualStrings(path, pane.file.?.path); + try std.testing.expectEqualSlices(u8, source, pane.file.?.content); + try std.testing.expect(pane.tag_init); + try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]); +} + +test "PdfSections Look follows the exact owning PDF, not an equal path" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + const first = p.panes[0].?; + // Bare normal-mode `f` on a PDF dispatches the PdfSections builtin. + p.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput; + const first_output = p.panes[first_output_id].?; + try std.testing.expect(panes.Pdf.isSectionsOutput(first_output)); + try std.testing.expect(first_output.file.?.content.len > 0); + + // A clean result is re-armed in place without rebuilding it. + const first_revision = first_output.file.?.revision; + panes.Pdf.openSections(p, 0); + try std.testing.expectEqual(first_output_id, first.search_pane.?); + try std.testing.expectEqual(first_revision, first_output.file.?.revision); + + const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; + const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0); + p.placeDoc(0, duplicate_id, duplicate); + layout.compute(p); + panes.Pdf.openSections(p, duplicate_id); + const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; + + const first_row = std.mem.sliceTo(first_output.file.?.content, '\n'); + const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len]; + p.lookAt(first_output_id, target); + try std.testing.expectEqual(@as(usize, 0), p.active); + p.lookAt(duplicate_output_id, target); + try std.testing.expectEqual(duplicate_id, p.active); + + // Once the original document is gone, its output cannot reinterpret an + // old ordinal against the still-open equal-path duplicate. + try std.testing.expect(pardes.test_api.runBuiltin(p, "Del", 0, "", null)); + p.active = first_output_id; + p.lookAt(first_output_id, target); + try std.testing.expectEqual(first_output_id, p.active); +} + +test "PdfSections caches an empty outline output" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path}); + const p = try Pardes.init(gpa, .{ .file = path }); + defer p.deinit(); + panes.Pdf.openSections(p, 0); + const pane = p.panes[0].?; + const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections; + try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len); + const serial = p.panes[output_id].?.serial; + panes.Pdf.openSections(p, 0); + try std.testing.expectEqual(output_id, pane.search_pane.?); + try std.testing.expectEqual(serial, p.panes[output_id].?.serial); +} + +test "SDL PDF raster policy is materially denser than Kitty" { + try std.testing.expect( + pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2, + ); + try std.testing.expect( + pardes.sdl_pdf_raster_policy.max_dimension > + pardes.kitty_pdf_raster_policy.max_dimension * 3, + ); + try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport); + try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport); +} + +test "watched PDF reload replaces MuPDF state and preserves the reading view" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const original = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(original); + const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa); + defer gpa.free(replacement); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint( + &path_buf, + ".zig-cache/tmp/{s}/live.pdf", + .{tmp.sub_path}, + ); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + var watched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == 0 and watch.on) { + watched = true; + }, + else => {}, + }; + try std.testing.expect(watched); + + const pane = p.panes[0].?; + const state = &pane.pdf.?; + try std.testing.expectEqual(@as(usize, 3), state.page_count); + panes.Pdf.openSections(p, 0); + const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput; + const sections = p.panes[sections_id].?; + try std.testing.expect(sections.file.?.content.len > 0); + + p.native_images = true; + state.fit = .height; + state.tint = .full; + state.pan_x = 1234; + state.pan_y = 4321; + try state.setSearchQuery(p.pdf_gpa, "needle"); + panes.Pdf.setPage(p, pane, 2); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + state.text_scroll = 7; + state.search_hit = 4; + state.search_reveal_pending = false; + const reveal_viewport_w = state.reveal_viewport_w; + const reveal_viewport_h = state.reveal_viewport_h; + const reveal_fit = state.reveal_fit; + const reveal_viewport_valid = state.reveal_viewport_valid; + const old_revision = panes.Pdf.rasterForPage(state, 2).?.revision; + const old_revision_counter = state.next_raster_revision; + const anchor_fraction: f64 = 0.375; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2])); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + pane.cur_row = 9; + pane.cur_col = 8; + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + + try std.testing.expectEqual(@as(usize, 1), state.page_count); + try std.testing.expectEqual(@as(usize, 0), state.page); + try std.testing.expectEqual(PdfFitMode.height, state.fit); + try std.testing.expectEqual(PdfTintMode.full, state.tint); + try std.testing.expectEqual(@as(u16, 1234), state.pan_x); + try std.testing.expectEqual(@as(u16, 4321), state.pan_y); + try std.testing.expectEqual(@as(usize, 7), state.text_scroll); + try std.testing.expect(state.text_scroll_clamp_pending); + try std.testing.expectEqualStrings("needle", state.search_query); + try std.testing.expectEqual(@as(usize, 4), state.search_hit); + try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w); + try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h); + try std.testing.expectEqual(reveal_fit, state.reveal_fit); + try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid); + try std.testing.expectEqual(old_revision_counter, state.next_raster_revision); + try std.testing.expectEqual(@as(usize, 0), state.rasters_len); + try std.testing.expect(state.layout_anchor_pending); + try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page); + try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001); + try std.testing.expectEqual(@as(i32, 0), pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.cur_col); + _ = state.ensureText(p.pdf_gpa); + const text_lines = std.mem.count(u8, state.text, "\n") + 1; + try std.testing.expect(state.text_scroll < text_lines); + try std.testing.expect(!state.text_scroll_clamp_pending); + + // A clean generated outline is derived data: it refreshes in place and + // remains the remembered output. This replacement deliberately has none. + try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len); + try std.testing.expectEqual(sections_id, state.sections_output.?.pane); + try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision); + + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const fresh_raster = panes.Pdf.rasterForPage(state, 0).?; + try std.testing.expect(fresh_raster.revision != old_revision); + try std.testing.expect(fresh_raster.revision > old_revision_counter); + const expected_scroll = anchor_fraction * + @as(f64, @floatFromInt(state.page_heights[0])); + const viewport = panes.Pdf.paneViewport(p, pane).?; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001); + + // Reopen is transactional: malformed replacement bytes leave the last + // good MuPDF handle and every durable setting untouched. + const good_handle = state.document.handle; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" }); + try std.testing.expect(!p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(good_handle, state.document.handle); + try std.testing.expectEqual(@as(usize, 1), state.page_count); + try std.testing.expectEqualStrings("needle", state.search_query); + try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null); + + // A user edit breaks the generated-revision token. A later valid reload + // updates the document but leaves those user-owned output bytes alone. + panes.File.setContent(p, §ions.file.?, try gpa.dupe(u8, "edited sections\n")); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(@as(usize, 3), state.page_count); + try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content); + + // The watch follows the PDF payload's lifetime just like a text file's; + // emit the stop while the slot still identifies the disappearing pane. + try p.deinitPane(pane); + p.panes[0] = null; + var unwatched = false; + while (p.nextEffect()) |effect| switch (effect) { + .watch => |watch| if (watch.pane == 0 and !watch.on) { + unwatched = true; + }, + else => {}, + }; + try std.testing.expect(unwatched); +} + +test "PDF reload does not re-center an already revealed matching search" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + const fixture = try pardes.filesystem.readFile(gpa, "docs/design.pdf"); + defer gpa.free(fixture); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path}); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const state = &pane.pdf.?; + try state.setSearchQuery(p.pdf_gpa, "Pardes"); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + try std.testing.expect(state.search_results != null); + try std.testing.expect(state.search_results.?.hit_count > 0); + try std.testing.expect(state.reveal_viewport_valid); + + // Reading moved on after the original reveal. A reload retains the query + // and rebuilds its quads, but it must not mistake that for a new request to + // jump back to the hit. + const anchor_page = state.page; + const anchor_fraction: f64 = 0.82; + state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page])); + state.scroll_to_page_pending = false; + state.layout_anchor_pending = false; + state.search_reveal_pending = false; + const reveal_w = state.reveal_viewport_w; + const reveal_h = state.reveal_viewport_h; + const reveal_fit = state.reveal_fit; + + // Same semantic document on a fresh generation keeps the expected anchor + // easy to state while still rebuilding every MuPDF-owned search object. + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture }); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(reveal_w, state.reveal_viewport_w); + try std.testing.expectEqual(reveal_h, state.reveal_viewport_h); + try std.testing.expectEqual(reveal_fit, state.reveal_fit); + try std.testing.expect(!state.search_reveal_pending); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + const page = @min(anchor_page, state.page_count - 1); + const anchored = @as(f64, @floatFromInt(state.page_starts[page])) + + anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page])); + const viewport = panes.Pdf.paneViewport(p, pane).?; + const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h)); + try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001); +} + +test "MuPDF pane renders, navigates, searches, and round-trips its page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(hasPdf(pane)); + try std.testing.expect(pane.pdf.?.page_count > 1); + try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); + try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); + const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); + try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); + + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const first = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), first.nimages); + const first_place = first.images[0].?; + try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); + try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); + const request = panes.Pdf.renderRequest( + panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport, + pdf_raster_policy, + ); + try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); + try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); + try std.testing.expectEqual( + request, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request, + ); + try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); + if (pdf_raster_policy.match_viewport) { + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); + try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); + try std.testing.expect(first_place.iw >= viewport.pixel_w or + @max(first_place.iw, first_place.ih) == request.max_dimension); + try std.testing.expect(first_place.ih >= viewport.pixel_h or + @max(first_place.iw, first_place.ih) == request.max_dimension); + } else { + try std.testing.expectEqual(@as(u32, 0), request.minimum_width); + try std.testing.expectEqual(@as(u32, 0), request.minimum_height); + } + + // SDL's raw dy path retains fractions in the placement itself; it does + // not leave native pixels fixed while only the underlying cells slide. + p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } }); + try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y); + _ = frame.reset(.retain_capacity); + const fractional = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision); + try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y); + pane.pdf.?.document_scroll_y = 0; + + // The default reading view moves one exact display-cell distance without + // replacing page pixels. Document placement, not texture identity, moves. + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y); + try std.testing.expectEqual( + first_place.native.revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + _ = frame.reset(.retain_capacity); + const panned = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); + try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y); + + const row_scroll = pane.pdf.?.document_scroll_y; + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y); + + // Counts survive a shared multi-key prefix. An invalid continuation is + // consumed and clears both prefix and count before the following action. + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '4' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = '?' } }); + try std.testing.expectEqual(.none, pane.normal.prefix); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); + + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const half_scroll = pane.pdf.?.document_scroll_y; + try std.testing.expect(half_scroll > row_scroll); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const counted_half = pane.pdf.?.document_scroll_y; + try std.testing.expectEqual(half_scroll * 2, counted_half); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); + try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4; + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll); + pane.pdf.?.document_scroll_y = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + const key_viewport = panes.Pdf.paneViewport(p, pane).?; + const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h)); + const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll); + try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); + try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y); + try std.testing.expectEqual( + first_place.native.revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + + // At a page boundary both page rasters coexist, the gap remains uncovered, + // and a row step crosses it without snapping either page to an edge. + // + // This lands by ASSIGNMENT, which is a jump and not a fling — and the + // travel counter has to say so: the keys above scrolled two screenfuls + // without any frame in between to spend that distance, which no shell does + // (every wheel batch is followed by a draw). Left unspent it would make the + // frame below the first frame of a fling and hand it bands. + const viewport = panes.Pdf.paneViewport(p, pane).?; + pane.pdf.?.scroll_travel = 0; + pane.pdf.?.document_scroll_y = @floatFromInt( + pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, + ); + _ = frame.reset(.retain_capacity); + const boundary = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 2), boundary.nimages); + try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page); + try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page); + const first_bottom = @as(f32, @floatFromInt( + boundary.images[0].?.native.geometry.?.dst.y + + boundary.images[0].?.native.geometry.?.dst.h, + )) + boundary.images[0].?.native.pixel_offset_y; + const second_top = @as(f32, @floatFromInt( + boundary.images[1].?.native.geometry.?.dst.y, + )) + boundary.images[1].?.native.pixel_offset_y; + const visible_gap = second_top - first_bottom; + try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX)); + try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1)); + const before_boundary_scroll = pane.pdf.?.document_scroll_y; + const page0_revision = boundary.images[0].?.native.revision; + const page1_revision = boundary.images[1].?.native.revision; + + // A scroll can activate the already-cached neighbor before the shell's + // next draw. Every consumer resolves the active page's resident raster, + // so a queued click cannot accidentally use page zero's dimensions. + pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5; + try std.testing.expectEqual( + page0_revision, + panes.Pdf.rasterForPage(&pane.pdf.?, 0).?.revision, + ); + try std.testing.expect(panes.Pdf.scrollPane(p, pane, 1)); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + try std.testing.expectEqual( + page1_revision, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + panes.Pdf.activatePage(p, pane, 0, false); + pane.pdf.?.document_scroll_y = before_boundary_scroll; + + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 2), second.nimages); + try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision); + try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); + + // Once page zero is wholly outside the viewport, both its owned RGBA and + // backend placement disappear; returning later renders a new raster. What + // does NOT go back is the memory: the departing page's bytes are parked in + // the relay and the arriving page of the same size takes them, so a fling + // never asks the allocator (or the kernel's fault handler) for megabytes it + // just gave up. + panes.Pdf.setPage(p, pane, 1); + _ = frame.reset(.retain_capacity); + const away = try p.render(frame.allocator()); + try std.testing.expect(away.nimages > 0); + for (away.images[0..away.nimages]) |maybe| if (maybe) |place| + try std.testing.expect(place.native.page != 0); + try std.testing.expect(panes.Pdf.rasterForPage(&pane.pdf.?, 0) == null); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); + const retired = pane.pdf.?.spare[0]; + panes.Pdf.setPage(p, pane, 0); + _ = frame.reset(.retain_capacity); + const returned = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page); + try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr); + try std.testing.expect(returned.images[0].?.native.revision != page0_revision); + + // PdfFit exists as a real builtin in this build. It resets placement but + // preserves the current page pixels; fit-height j/k remains continuous in + // the same document-pixel coordinate space. + const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse + return error.MissingPdfFitBuiltin; + const revision_before_toggle = panes.Pdf.rasterForPage( + &pane.pdf.?, + pane.pdf.?.page, + ).?.revision; + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(fit_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual( + revision_before_toggle, + panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, + ); + const height_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); + for (p.panes) |slot| { + const other = slot orelse continue; + if (hasPdf(other)) continue; + panes.Pdf.toggleFit(other); // pane-scoped and deliberately inert here + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + break; + } + + // A fit-height landscape page exposes horizontal overflow to a horizontal + // wheel without rerasterizing. Use synthetic dimensions only for the + // geometry check; no frame is drawn while they differ from the buffer. + const active_raster = panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?; + const saved_iw = active_raster.iw; + const saved_ih = active_raster.ih; + active_raster.iw = 2000; + active_raster.ih = 500; + panes.Pdf.horizontalWheel(p, pane, 1); + try std.testing.expect(pane.pdf.?.pan_x > 0); + try std.testing.expectEqual(revision_before_toggle, active_raster.revision); + pane.pdf.?.pan_x = 0; + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expect(pane.pdf.?.pan_x > 0); + p.update(.{ .key = .{ .cp = '$' } }); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16)); + p.update(.{ .key = .{ .cp = '0' } }); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'l' } }); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + active_raster.iw = saved_iw; + active_raster.ih = saved_ih; + pane.pdf.?.pan_x = 0; + + const before_height_scroll = pane.pdf.?.document_scroll_y; + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); + + const revision_before_search = active_raster.revision; + try p.runSearch(0, "Pardes", .text, .top); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + try std.testing.expectEqual(revision_before_search, active_raster.revision); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const results = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); + // n SELECTS the first result row and opens nothing: the walk's only list + // here is the unlooked +Search buffer, so focus lands THERE. Enter is what + // jumps, and the document query survives both. + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expectEqual(results_id, p.active); + const results_pane = p.panes[results_id].?; + try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit); + try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row); + try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col); + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + + // Search state is owned and untruncated, and changing pages invalidates + // only page-local state while retaining the document query. + const long_query = "a query deliberately longer than any tag display budget: " ++ + "012345678901234567890123456789012345678901234567890123456789" ++ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; + panes.Pdf.setPage(p, pane, next_page); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + + // PDF dumps intentionally retain the existing image-compatible schema: + // page/path and the exact editable tail are restored, while pane-local + // tint starts from the fresh-PDF default rather than being serialized. + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint); + pane.pdf.?.fit = .height; + const custom_tail = " Keep Del"; + @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail); + pane.tag_tail_len = custom_tail.len; + pane.tag_init = true; + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); + defer restored.deinit(); + try std.testing.expect(hasPdf(restored.panes[0].?)); + try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); + try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit); + try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); + const restored_pane = restored.panes[0].?; + try std.testing.expect(restored_pane.tag_init); + try std.testing.expectEqualStrings( + custom_tail, + restored_pane.tag_tail[0..restored_pane.tag_tail_len], + ); +} + +test "a fling's banded pages show the reader exactly what whole pages would" { + if (!pdf_enabled or platform == .web) return; + + // The contract fast scrolling is allowed to change: HOW pixels are carried + // (a strip of a page instead of the page) but never WHICH pixels arrive. So + // the same frame is drawn twice — once at fling speed, once at reading + // speed — and every pixel inside every source rectangle must match, along + // with where on screen it goes. + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + // Land mid-page-boundary so the frame carries TWO pages, each showing a + // fraction of itself — the shape a fling actually produces. + const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); + const Shot = struct { + page: u32, + dst: image.PixelRect, + pixels: []u8, + }; + var shots: [8]Shot = undefined; + var shots_len: usize = 0; + defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); + + // A fling: one frame's worth of wheel travel carrying the viewport more + // than a screenful, delivered through the real scroll path so the distance + // is counted the way a wheel batch counts it. + pv.document_scroll_y = 0; + pv.scroll_travel = 0; + try std.testing.expect(panes.Pdf.scrollPane(p, pane, landing)); + _ = frame.reset(.retain_capacity); + const flung = try p.render(frame.allocator()); + try std.testing.expect(flung.nimages >= 2); + var banded = false; + for (flung.images[0..flung.nimages]) |maybe| { + const place = maybe orelse continue; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + if (raster.band_h < raster.ih) banded = true; + try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); + shots[shots_len] = .{ + .page = place.native.page, + .dst = geometry.dst, + .pixels = try copySourceRect(gpa, place, geometry.src), + }; + shots_len += 1; + } + // ...and it really did band, or the comparison below is two identical + // whole-page renders agreeing with each other. + try std.testing.expect(banded); + // ...at reading speed: no travel at all since the frame above, so every + // page is rasterized whole again, and that is the picture the banded frame + // has to have matched. + _ = frame.reset(.retain_capacity); + const rested = try p.render(frame.allocator()); + try std.testing.expectEqual(shots_len, rested.nimages); + for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { + const place = maybe orelse return error.MissingPdfPlacement; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest + try std.testing.expectEqual(shot.page, place.native.page); + try std.testing.expectEqual(shot.dst.x, geometry.dst.x); + try std.testing.expectEqual(shot.dst.y, geometry.dst.y); + try std.testing.expectEqual(shot.dst.w, geometry.dst.w); + try std.testing.expectEqual(shot.dst.h, geometry.dst.h); + const whole = try copySourceRect(gpa, place, geometry.src); + defer gpa.free(whole); + try std.testing.expectEqualSlices(u8, shot.pixels, whole); + } +} + +/// The pixels a backend samples out of one placement: the source rectangle, +/// row by row, at the texture's own stride. Test-only, and the one operation +/// that makes "same picture" mean something when the textures differ in shape. +fn copySourceRect( + gpa: std.mem.Allocator, + place: ImagePlace, + src: image.PixelRect, +) ![]u8 { + const stride = place.iw * 4; + const row_len = @as(usize, src.w) * 4; + const out = try gpa.alloc(u8, row_len * src.h); + errdefer gpa.free(out); + var row: usize = 0; + while (row < src.h) : (row += 1) { + const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; + @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); + } + return out; +} + +test "PDF normal adapter consumes unsupported actions and navigates page fallback" { + if (!pdf_enabled or platform == .web) return; + + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + try std.testing.expect(pv.page_count > 3); + p.native_images = false; + + // Every vertical vocabulary falls back to counted page changes when the + // shell cannot place native pixels. + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'u', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + + // Prefix actions and the counted text goto-line action map to pages. + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'e' } }); + try std.testing.expectEqual(pv.page_count - 1, pv.page); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + try std.testing.expectEqual(@as(usize, 0), pv.page); + p.update(.{ .key = .{ .cp = '3' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + try std.testing.expectEqual(@as(usize, 2), pv.page); + p.update(.{ .key = .{ .cp = '2' } }); + p.update(.{ .key = .{ .cp = 'G' } }); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + // Editing/selection actions are consumed no-ops: parser state clears, + // placeholder cells never acquire a range, and PDF state stays intact. + const page_before_noop = pv.page; + const revision_before_noop = pv.next_raster_revision; + p.update(.{ .key = .{ .cp = '4' } }); + p.update(.{ .key = .{ .cp = 'd' } }); + p.update(.{ .key = .{ .cp = 'v' } }); + p.update(.{ .key = .{ .cp = '|' } }); + try std.testing.expectEqual(page_before_noop, pv.page); + try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + try std.testing.expectEqual(.none, pane.normal.prefix); + try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0); + try std.testing.expect(!pane.tag_edit); + + // Cross-pane BODY-NORMAL actions keep their established shared paths. + p.update(.{ .key = .{ .cp = ':' } }); + try std.testing.expect(pane.tag_edit); + try std.testing.expectEqual(Mode.normal, pane.mode); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); + p.update(.{ .key = .{ .cp = ' ' } }); + try std.testing.expect(p.leader_on); + p.update(.{ .key = .{ .cp = '!' } }); + try std.testing.expect(!p.leader_on); + p.update(.{ .key = .{ .cp = '/' } }); + try std.testing.expect(pane.tag_edit); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.tag_edit); +} + +test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 28, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + // A second pane, then focus it and come back: `Last` has somewhere to go + // and the PDF is where the keys land. + panes.Pdf.openSections(p, 0); + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + + // Everything a reader can see over the page: a search overlay and a live + // word selection. + try pv.setSearchQuery(p.pdf_gpa, "Pardes"); + var found = try pv.document.search(gpa, pv.page, "Pardes"); + defer found.deinit(gpa); + try std.testing.expect(found.quads.len > 0); + const quad = found.quads[0].quad; + try std.testing.expectEqual( + panes.Pdf.SelectionUpdate.changed, + pv.setSelection(p.pdf_gpa, quad.ul, quad.lr, false), + ); + p.update(.{ .key = .{ .cp = '3' } }); + const page_before = pv.page; + const scroll_before = pv.document_scroll_y; + + // Escape drops the overlay and the selection, keeps the reading position, + // and does NOT hand the keyboard to another pane. + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expect(pv.selection == null and pv.selection_text.len == 0); + try std.testing.expectEqual(@as(usize, 0), pv.search_query.len); + try std.testing.expect(pv.search_results == null); + try std.testing.expect(!pv.highlights_dirty and !pv.search_reveal_pending); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); + try std.testing.expectEqual(@as(u32, 0), pane.normal.count); + try std.testing.expectEqual(.none, pane.normal.prefix); + // A second Escape on a bare document is inert rather than an exit. + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + + // Shift-Escape is the way out, and it leaves the document as it found it. + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); +} + +test "PDF continuous strip renders every intersecting short page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + try std.testing.expect(pv.page_count > 3); + p.native_images = true; + + // A legal wide MediaBox can make more than three pages intersect one + // viewport. Seed tiny matching rasters so this tests transport/cache + // cardinality without spending the unit test rendering fake page sizes. + for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 }; + pv.layout_valid = false; + pv.scroll_to_page_pending = true; + const viewport = panes.Pdf.ensurePaneLayout(p, pane).?; + const visible = panes.Pdf.visiblePages(pv, viewport); + try std.testing.expectEqual(pv.page_count, visible.len); + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy); + try std.testing.expect(pv.page_count <= pv.rasters.len); + for (0..pv.page_count) |page| { + const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); + @memset(rgba, @intCast(page)); + pv.rasters[pv.rasters_len] = .{ + .valid = true, + .page = page, + .rgba = rgba, + .iw = viewport.pixel_w, + .ih = 1, + .request = request, + .request_valid = true, + .tried = true, + .tint_key = .{ .mode = pv.tint, .colors = panes.Pdf.tintColors(p) }, + .revision = @intCast(page + 1), + }; + pv.rasters_len += 1; + } + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + try std.testing.expectEqual(pv.page_count, surface.nimages); + try std.testing.expectEqual(pv.page_count, pv.rasters_len); + for (surface.images[0..surface.nimages], 0..) |maybe, page| { + const place = maybe orelse return error.MissingShortPdfPage; + try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); + try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h); + } +} + +test "PdfTint cycles pane-local state and exposes it in the live PDF tag" { + if (!pdf_enabled or platform == .web) return; + + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse + return error.MissingPdfTintBuiltin; + + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf( + u8, + initial_tag, + "width PdfFit filtered PdfTint", + ) != null); + + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + const full_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null); + + // Running the same pane-scoped word in a terminal cannot mutate the PDF + // next to it. A FILE boot is one pane now, so that terminal is asked for + // here rather than inherited from init. + _ = try p.newShell(1, ""); + _ = layout.splitColumn(p, 0, 1, false); + pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did + try std.testing.expect(!hasPdf(p.panes[1].?)); + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 1, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); + const disabled_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null); + try std.testing.expect(pardes.test_api.runBuiltin( + p, + @tagName(tint_builtin), + 0, + "", + null, + )); + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + try std.testing.expectEqual(PdfFitMode.width, pv.fit); + + // `dark` intentionally leaves page bg/fg null. PDF tint resolves those + // deterministically to its chrome colors rather than host defaults. + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + p.setThemeIndex(dark_index); + const colors = panes.Pdf.tintColors(p); + try std.testing.expectEqual(p.theme().tag_bg, colors.background); + try std.testing.expectEqual(p.theme().tag_fg, colors.foreground); +} + +test "PDF tint and tinted theme changes rebuild every visible raster only" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + _ = try p.render(frame.allocator()); + const viewport = panes.Pdf.paneViewport(p, pane).?; + pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2); + _ = frame.reset(.retain_capacity); + const default_surface = try p.render(frame.allocator()); + try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); + try std.testing.expectEqual(@as(usize, 2), default_surface.nimages); + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); + + const Snapshot = struct { + page: u32, + revision: u32, + geometry: image.NativeGeometry, + pixel_offset_y: f32, + iw: usize, + ih: usize, + checksum: u64, + }; + const Capture = struct { + fn get(surface: *const Surface) ![2]Snapshot { + if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount; + var out: [2]Snapshot = undefined; + for (&out, 0..) |*snapshot, i| { + const place = surface.images[i] orelse return error.MissingVisiblePdf; + snapshot.* = .{ + .page = place.native.page, + .revision = place.native.revision, + .geometry = place.native.geometry orelse return error.MissingPdfGeometry, + .pixel_offset_y = place.native.pixel_offset_y, + .iw = place.iw, + .ih = place.ih, + .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba), + }; + } + return out; + } + + fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void { + for (before, after) |old, new| { + try std.testing.expectEqual(old.page, new.page); + try std.testing.expectEqual(old.geometry, new.geometry); + try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y); + try std.testing.expectEqual(old.iw, new.iw); + try std.testing.expectEqual(old.ih, new.ih); + } + } + }; + const default_filtered = try Capture.get(default_surface); + for (pv.rasters[0..pv.rasters_len]) |raster| + try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode); + + const dark_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "dark")) break i; + } else return error.MissingDarkTheme; + p.setThemeIndex(dark_index); + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); + _ = frame.reset(.retain_capacity); + const themed_surface = try p.render(frame.allocator()); + const themed = try Capture.get(themed_surface); + try Capture.expectGeometry(default_filtered, themed); + for (default_filtered, themed) |old, new| { + try std.testing.expect(new.revision > old.revision); + try std.testing.expect(new.checksum != old.checksum); + } + + // Chrome animation reads a separate palette. Its ticks must never disturb + // the target-theme tint key or ask MuPDF for the same pixels again. + try std.testing.expect(p.animationActive()); + for (0..layout.Animation.transition_steps) |_| { + p.update(.tick); + _ = frame.reset(.retain_capacity); + const tick_surface = try p.render(frame.allocator()); + const ticked = try Capture.get(tick_surface); + try Capture.expectGeometry(themed, ticked); + for (themed, ticked) |once, after_tick| { + try std.testing.expectEqual(once.revision, after_tick.revision); + try std.testing.expectEqual(once.checksum, after_tick.checksum); + } + } + try std.testing.expect(!p.animationActive()); + + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.full, pv.tint); + _ = frame.reset(.retain_capacity); + const full_surface = try p.render(frame.allocator()); + const full = try Capture.get(full_surface); + try Capture.expectGeometry(themed, full); + for (themed, full) |old, new| { + try std.testing.expect(new.revision > old.revision); + } + for (pv.rasters[0..pv.rasters_len]) |raster| + try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode); + + panes.Pdf.toggleTint(pane); + try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); + _ = frame.reset(.retain_capacity); + const disabled_surface = try p.render(frame.allocator()); + const disabled = try Capture.get(disabled_surface); + try Capture.expectGeometry(full, disabled); + for (full, disabled) |old, source| { + try std.testing.expect(source.revision > old.revision); + try std.testing.expect(source.checksum != old.checksum); + } + + const acme_index = for (themes, 0..) |th, i| { + if (std.mem.eql(u8, th.name, "acme")) break i; + } else return error.MissingAcmeTheme; + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); + p.setThemeIndex(acme_index); + // Disabled keys ignore theme colors, so neither explicit invalidation nor + // the per-raster key comparison asks MuPDF for replacement pixels. + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); + _ = frame.reset(.retain_capacity); + const unchanged_surface = try p.render(frame.allocator()); + const unchanged = try Capture.get(unchanged_surface); + try Capture.expectGeometry(disabled, unchanged); + for (disabled, unchanged) |old, new| { + try std.testing.expectEqual(old.revision, new.revision); + try std.testing.expectEqual(old.checksum, new.checksum); + } + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); +} + +test "PDF resize preserves page-relative document position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + panes.Pdf.setPage(p, pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.375; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.layout_valid); + try std.testing.expect(pv.layout_anchor_pending); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const resized_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + const held = pv.document_scroll_y; + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expectEqual(held, pv.document_scroll_y); + try std.testing.expect(pv.layout_valid); +} + +test "PDF pane geometry change preserves its page-relative position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + // A FILE boot is one pane now and a lone column always fills the window, + // so the divider drag below needs a second column to take the width from. + _ = try p.newShell(1, ""); + _ = layout.splitColumn(p, 0, 1, false); + pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + panes.Pdf.setPage(p, pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.625; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + const old_width = pv.layout_viewport_w; + + // A divider/split changes rects through computeGeom without emitting a + // shell resize. panes.Pdf.ensurePaneLayout anchors against the old layout + // before rebuilding it for this wider pane. + const sibling_weight = p.col_weight[1]; + p.col_weight[0] = sibling_weight * 6; + layout.compute(p); + try std.testing.expect(panes.Pdf.paneViewport(p, pane).?.pixel_w != old_width); + try std.testing.expect(pv.layout_valid); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const changed_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + // An explicit pending reveal wins over an implicit layout anchor. + pv.page = 2; + pv.scroll_to_page_pending = true; + p.col_weight[0] = sibling_weight * 2; + layout.compute(p); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual( + @as(f64, @floatFromInt(pv.page_starts[2])), + pv.document_scroll_y, + ); +} + +test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 120, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + var page_hits = try pv.document.search(gpa, 0, "Pardes"); + defer page_hits.deinit(gpa); + try std.testing.expect(page_hits.hit_count >= 3); + + try p.runSearch(0, "Pardes", .text, .top); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const rows = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); + + // Exaggerate the cell aspect only to make the three fixture hits occupy + // distinct fit-width crops. The search/reveal math must use the same + // reported metrics as placement, whatever a backend reports. + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ .w = 16, .h = 4 }, + } }); + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 0), pv.search_hit); + const first = try p.render(frame.allocator()); + const revision = first.images[0].?.native.revision; + const raster = panes.Pdf.rasterForPage(pv, pv.page).?; + const first_scroll = pv.document_scroll_y; + const first_results = pv.search_results orelse return error.MissingPdfPageSearch; + const first_quad = for (first_results.quads) |item| { + if (item.hit == 0) break item.quad; + } else return error.MissingFirstPdfHit; + const first_y = panes.Pdf.normalizedPixel( + (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, + raster.ih, + ); + const first_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(first_y >= first_geometry.src.y and + first_y < first_geometry.src.y + first_geometry.src.h); + + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 1), pv.search_hit); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, second.images[0].?.native.revision); + + try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); + try std.testing.expectEqual(@as(usize, 2), pv.search_hit); + _ = frame.reset(.retain_capacity); + const third = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, third.images[0].?.native.revision); + try std.testing.expect(pv.document_scroll_y != first_scroll); + const third_results = pv.search_results orelse return error.MissingPdfPageSearch; + const third_quad = for (third_results.quads) |item| { + if (item.hit == 2) break item.quad; + } else return error.MissingThirdPdfHit; + const third_y = panes.Pdf.normalizedPixel( + (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, + raster.ih, + ); + const third_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(third_y >= third_geometry.src.y and + third_y < third_geometry.src.y + third_geometry.src.h); +} + +test "PDF native mouse selection, Look, and highlights share page geometry" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + // A host without Kitty/native pixels keeps the projected-text contract: + // j/k can still change pages and a body drag remains a generic selection. + const fallback = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), fallback.nimages); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + const fallback_rect = p.rects[0]; + const fallback_col = fallback_rect.x + config.GUTTER + 1; + const fallback_row = fallback_rect.y + BOX_H + 1; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); + try std.testing.expectEqual(.done, pane.sel[sel_slot].state); + try std.testing.expect(pane.pdf.?.selection == null); + pane.sel[sel_slot].state = .none; + + p.native_images = true; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + const plain_revision = plain.images[0].?.native.revision; + + var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); + defer found.deinit(gpa); + const quad = found.quads[0].quad; + const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; + const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; + const pv = &pane.pdf.?; + const raster = panes.Pdf.rasterForPage(pv, pv.page).?; + const source_x: u32 = @intCast(@min( + raster.iw - 1, + @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))), + )); + const source_y: u32 = @intCast(@min( + raster.ih - 1, + @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))), + )); + + // Bring the known word into the fit-width crop, then invert the shared + // source/destination geometry to the nearest body cell. + var geometry = panes.Pdf.paneGeometry(p, pane).?; + if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { + const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h; + const wanted = @min(overflow, source_y -| geometry.src.h / 2); + pv.pan_y = if (overflow == 0) 0 else @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + geometry = panes.Pdf.paneGeometry(p, pane).?; + } + const pixel_x = geometry.dst.x + @as(u32, @intCast( + @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, + )); + const pixel_y = geometry.dst.y + @as(u32, @intCast( + @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, + )); + const r = p.rects[0]; + const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); + const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); + + var selected_col: ?u16 = null; + var selected_row: u16 = 0; + const nearby = [_]i32{ 0, -1, 1, -2, 2 }; + find_word: for (nearby) |dy| for (nearby) |dx| { + const col: u16 = @intCast(std.math.clamp( + base_col + dx, + @as(i32, r.x + config.GUTTER), + @as(i32, r.x + r.w - 1), + )); + const row: u16 = @intCast(std.math.clamp( + base_row + dy, + @as(i32, r.y + BOX_H), + @as(i32, r.y + r.h - 1), + )); + if (!panes.Pdf.beginSelection(p, pane, col, row)) continue; + if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { + selected_col = col; + selected_row = row; + break :find_word; + } + }; + const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; + try std.testing.expect(std.ascii.indexOfIgnoreCase( + pardes.test_api.heldSelection(p, 0).?, + "Pardes", + ) != null); + + _ = frame.reset(.retain_capacity); + const selected_frame = try p.render(frame.allocator()); + const selected_revision = selected_frame.images[0].?.native.revision; + try std.testing.expect(selected_revision != plain_revision); + + // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the + // same texture generation instead of retransmitting identical pixels. + try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row)); + try std.testing.expect(raster.tried); + _ = frame.reset(.retain_capacity); + const duplicate = try p.render(frame.allocator()); + try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); + + // Delayed native hover retains independent quads/text and changes only + // presentation state. In particular it must not borrow the click path, + // whose page activation and persistent selection are intentional effects + // of a gesture rather than observation. + if (config.look_preview_delay_frames) |delay| { + const active_before = p.active; + const page_before = pv.page; + const scroll_before = pv.document_scroll_y; + const sels_before = pane.sel; + const cursor_before = .{ pane.cur_row, pane.cur_col }; + const modal_before = .{ pane.msel, pane.vsel, pane.nsel }; + const selection_before = pv.selection orelse return error.MissingPdfSelection; + const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads); + defer gpa.free(selection_quads_before); + const selection_text_before = try gpa.dupe(u8, pv.selection_text); + defer gpa.free(selection_text_before); + const selection_anchor_before = pv.selection_anchor; + const selection_head_before = pv.selection_head; + const query_before = try gpa.dupe(u8, pv.search_query); + defer gpa.free(query_before); + const search_hit_before = pv.search_hit; + const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; + const effects_before = p.effects_len; + try std.testing.expect(p.drag == .none); + + const hover_motion = Event{ .mouse = .{ + .button = .none, + .kind = .motion, + .col = word_col, + .row = selected_row, + } }; + p.update(hover_motion); + try std.testing.expect(p.look_hover_wait != null); + for (0..delay) |_| p.update(.tick); + const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview; + try std.testing.expectEqual(@as(usize, 0), hover.probe.page); + try std.testing.expect(hover.probe.quads.len > 0); + try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null); + try std.testing.expect(p.look_hover_preview == null); + try std.testing.expect(p.look_hover_wait == null); + + const decorated = try panes.Pdf.buildHighlights( + pv, + p.scratch.allocator(), + panes.Pdf.highlightInput(p, 0, pane), + ); + const page_highlights = decorated.forPage(pv.page, pv.page); + try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind); + try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind); + _ = frame.reset(.retain_capacity); + const hovered_frame = try p.render(frame.allocator()); + const hovered_revision = hovered_frame.images[0].?.native.revision; + try std.testing.expect(hovered_revision != selected_revision); + + p.update(.pointer_leave); + try std.testing.expect(p.pdf_hover_preview == null); + _ = frame.reset(.retain_capacity); + const unhovered_frame = try p.render(frame.allocator()); + try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision); + + try std.testing.expectEqual(active_before, p.active); + try std.testing.expectEqual(page_before, pv.page); + try std.testing.expectEqual(scroll_before, pv.document_scroll_y); + try std.testing.expect(std.meta.eql(sels_before, pane.sel)); + try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); + try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel })); + const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection; + try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads); + try std.testing.expectEqualStrings(selection_text_before, pv.selection_text); + try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor)); + try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head)); + try std.testing.expectEqualStrings(query_before, pv.search_query); + try std.testing.expectEqual(search_hit_before, pv.search_hit); + try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); + try std.testing.expectEqual(effects_before, p.effects_len); + try std.testing.expect(p.drag == .none); + } + + // A native right-click resolves the same MuPDF-snapped word and feeds it + // to Look. Search highlights precede selection highlights so the live + // selection remains visually authoritative where they overlap. + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); + pv.resolveSearch(p.pdf_gpa); + const highlights = (try panes.Pdf.buildHighlights( + pv, + p.scratch.allocator(), + panes.Pdf.highlightInput(p, 0, pane), + )).items; + try std.testing.expect(highlights.len > 1); + try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); + try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); + const revision_before_reveal = raster.revision; + const max_document_scroll = @as(f64, @floatFromInt( + pv.document_height -| panes.Pdf.paneViewport(p, pane).?.pixel_h, + )); + pv.document_scroll_y = max_document_scroll; + panes.Pdf.revealSearch(pv, panes.Pdf.paneViewport(p, pane).?, panes.Pdf.paneGeometry(p, pane)); + const revealed_scroll = pv.document_scroll_y; + try std.testing.expect(revealed_scroll != max_document_scroll); + try std.testing.expectEqual(revision_before_reveal, raster.revision); + pv.document_scroll_y = max_document_scroll; + panes.Pdf.revealSearch( + pv, + panes.Pdf.paneViewport(p, pane).?, + panes.Pdf.paneGeometry(p, pane), + ); // one shot: subsequent manual pan stays put + try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y); + pv.document_scroll_y = revealed_scroll; + _ = frame.reset(.retain_capacity); + const searched = try p.render(frame.allocator()); + try std.testing.expect(searched.images[0].?.native.revision != selected_revision); + + // Fit and viewport changes can crop a hit which was already revealed. + // Fit remains placement-only. SDL's physical-viewport quality request + // changes with a resize and therefore replaces pixels; Kitty's fixed, + // bandwidth-conscious request retains them. + const revision_before_geometry_change = searched.images[0].?.native.revision; + pv.search_reveal_pending = false; + panes.Pdf.toggleFit(pane); + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + panes.Pdf.toggleFit(pane); // restore the reading-width geometry + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + const original_cell_pixels = p.cell_pixels; + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.search_reveal_pending); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ + .w = original_cell_pixels.w, + .h = original_cell_pixels.h + 1, + }, + } }); + try std.testing.expect(pv.search_reveal_pending); + _ = frame.reset(.retain_capacity); + const geometry_changed = try p.render(frame.allocator()); + if (pdf_raster_policy.match_viewport) + try std.testing.expect(geometry_changed.images[0].?.native.revision != + revision_before_geometry_change) + else + try std.testing.expectEqual( + revision_before_geometry_change, + geometry_changed.images[0].?.native.revision, + ); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = original_cell_pixels, + } }); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + // Unsupported text-selection actions are consumed by the PDF adapter: + // they neither invent a range over placeholder cells nor disturb the + // native MuPDF selection. The select-button's no-drag click still clears. + p.update(.{ .key = .{ .cp = 'v' } }); + try std.testing.expect(pv.selection != null); + try std.testing.expect(!pane.vsel.active); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(pv.selection == null); + try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row)); + + // Native drag events update MuPDF quads and copied text immediately, but + // keep the already transmitted page generation stable until release. + _ = frame.reset(.retain_capacity); + const before_drag = try p.render(frame.allocator()); + const before_drag_revision = before_drag.images[0].?.native.revision; + const drag_col = @min(r.x + r.w - 1, word_col +| 12); + const drag_row = @min(r.y + r.h - 1, selected_row +| 4); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .press, + .col = word_col, + .row = selected_row, + } }); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = @min(drag_col, word_col +| 4), + .row = @min(drag_row, selected_row +| 2), + } }); + _ = frame.reset(.retain_capacity); + const during_first_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_first_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(p.drag.select.pdf.selection_changed); + _ = frame.reset(.retain_capacity); + const during_second_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_second_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .release, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(!raster.tried); + _ = frame.reset(.retain_capacity); + const committed_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision + 1, + committed_drag.images[0].?.native.revision, + ); + _ = frame.reset(.retain_capacity); + const stable_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + committed_drag.images[0].?.native.revision, + stable_drag.images[0].?.native.revision, + ); + + const saved_query = try gpa.dupe(u8, pv.search_query); + defer gpa.free(saved_query); + panes.Pdf.setPage(p, pane, 1); + try std.testing.expect(pv.selection == null); + try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); + try std.testing.expectEqualStrings(saved_query, pv.search_query); +} + +test "Esc back into a PDF keeps the offset within its page" { + if (!pdf_enabled or platform == .web) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try pdf_impl.makeOutlineTestPdf(gpa); + defer gpa.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture }); + var path_buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path}); + + const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 28 } }); + const doc = p.active; + const dp = p.panes[doc].?; + try std.testing.expect(dp.pdf != null); + pardes.test_api.sync(p); + + // Read a little way DOWN the page you are on, then step away. + const pv = &dp.pdf.?; + pv.document_scroll_y += 137; + const mid = pv.document_scroll_y; + pv.scroll_to_page_pending = false; + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + pardes.test_api.sync(p); + const shell = p.active; + try std.testing.expect(shell != doc); + + p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF + pardes.test_api.sync(p); + try std.testing.expectEqual(doc, p.active); + + // The jumps stack records a PDF as its PAGE, so returning revealed the page + // you were already on — and a reveal sets `document_scroll_y` to that page's + // start, throwing away where you had read to inside it. + try std.testing.expectEqual(mid, pv.document_scroll_y); + // Same reveal, the other half: with no valid layout yet it only ARMS the + // snap, so a test that watched the offset alone would not see it coming. + try std.testing.expect(!pv.scroll_to_page_pending); +} diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index baf89443..b08af552 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -173,7 +173,7 @@ fn measureCachedReopen(path: []const u8, config: Config) !Result { // memory clobber makes each production call's reads and writes // independently observable to the optimizer. compilerBarrier(); - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); compilerBarrier(); observed +%= observeReopen(prepared); } @@ -207,7 +207,7 @@ fn measureCachedControl(path: []const u8, config: Config) !Result { observed +%= observeReopen(prepared); } const elapsed = nowNs() -| started; - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); var identity = sectionsOutputIdentity(core); identity.checksum = mix(identity.checksum, observed); try verifyIdentity("sections-perturb-observe-control", &expected, identity, round); @@ -269,7 +269,7 @@ fn preparedCore(path: []const u8) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); errdefer core.deinit(); drainEffects(core); - pardes.pdf_pane.openSections(core, 0); + pardes.panes.Pdf.openSections(core, 0); drainEffects(core); _ = sectionsOutputIdentity(core); return core; diff --git a/test/perf.zig b/test/perf.zig index 171bbfbc..5aa5c4d4 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -1,65 +1,29 @@ -//! The editing scoreboard: how long one user gesture costs against file size. -//! -//! zig build perf -- the table -//! zig build perf -- --json -- the same, machine-readable -//! zig build perf -- --base old.json -- table + a before/after delta column -//! zig build perf -- --reps 60 -- more samples (default 25) -//! -//! It drives the core directly — feed an Event, drain the effects, -//! call render — exactly like test/hxdiff.zig, so there is no pty, no shell -//! and no scheduler between the clock and the code under test. A run is -//! self-contained: the fixtures are generated into /tmp/pardes-perf here and -//! rewritten every run, so the numbers do not move when this repo's own -//! sources do and there is no stale input to chase. -//! -//! WHAT A CELL MEANS. Every input row is the WHOLE gesture: update(event), -//! drain the effect queue, render one frame into a fresh arena — what a user -//! waits for between pressing a key and seeing the screen. `render` on its own -//! is the redraw with nothing changed (a resize, a refresh), which is the floor -//! every other row sits on. -//! -//! WHERE THE CURSOR IS matters more than anything else here: several of the -//! core's line lookups are scans from byte 0, so a measurement taken at line 3 -//! of a 200k-line file reports a cost the user never pays. Each sample seeks to -//! a different position spread across the whole file BEFORE the clock starts, -//! so the median is the cost at a typical place in the document, not at its -//! top. +//! End-to-end gesture latency: update, effects, render. +//! Each timed sample starts at a deterministic position across its fixture. +//! Compare builds with the same harness, geometry, fixtures and repetition count. const std = @import("std"); const libc = std.c; const pardes = @import("pardes"); +const build_identity = @import("perf_options").identity; +const BuildIdentity = @TypeOf(build_identity); -// ghostty-vt narrates every sequence it does not implement, and the fixture -// text goes nowhere near a terminal here — cut the libraries to errors so the -// table is the only thing on the screen. pub const std_options: std.Options = .{ .log_level = .err }; const gpa = std.heap.page_allocator; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("perf.zig"))}); +}; -/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig and -/// test/lspbench.zig already use. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec)); } -/// The viewport every fixture is measured in. Bigger than the 80x24 the helix -/// harness pins (that one matches helix's own harness; this one wants a screen -/// somebody actually works in), and fixed, because half of these numbers scale -/// with the number of cells on the screen. -/// The viewport every measurement runs in. Overridable, because the SHAPE of the -/// screen is one of the things this table exists to hold constant while something -/// else varies - and the ESP32-P4 firmware runs a 40x12 grid, where a render costs -/// 47x what it costs here for a tenth of the cells. Profiling that needs the same -/// geometry, not a scaled guess. var screen_cols: u16 = 120; var screen_rows: u16 = 40; -/// The four shapes a file comes in. `lines` x `cols` is the generated body; -/// the point of the pair is that "big" has two different meanings and they -/// break different code — a 200k-line file is long in the LINE index, an -/// 8000-column file is long inside ONE line, and a scan that is fine for one -/// is quadratic for the other. const Fixture = struct { name: []const u8, lines: usize, @@ -67,36 +31,20 @@ const Fixture = struct { }; const fixtures = [_]Fixture{ - // the control: a normal source file. Anything that shows up here is a - // constant cost, not a scaling one. .{ .name = "small", .lines = 1_000, .cols = 60 }, .{ .name = "medium", .lines = 50_000, .cols = 60 }, - // "a few MB, hundreds of thousands of lines" .{ .name = "large", .lines = 300_000, .cols = 60 }, - // the other failure mode: few lines, each one wider than any screen .{ .name = "longline", .lines = 400, .cols = 8_000 }, - // The ESP32-P4 firmware's actual document: a handful of lines, one of them wider than its - // 40-column screen. Here because a profile taken on `small` is a profile of a 1,000-line line - // index, and the board has twelve lines - so the costs that dominate there are not the costs - // that dominate it. Pair with `--cols 40 --rows 12`. .{ .name = "esp32p4", .lines = 12, .cols = 240 }, }; const Op = enum { - /// Look at the path: disk read, pane creation, layout, first frame. open, - /// one redraw, nothing changed render, - /// `j` — one row of cursor movement key_down, - /// `l` — one column. On `longline` the cursor sits deep in the line, so - /// this is the horizontal-scroll path; elsewhere it is a plain step. key_right, - /// PageDown page_down, - /// one mouse wheel tick wheel, - /// one printable typed in insert mode edit_char, fn label(o: Op) []const u8 { @@ -112,46 +60,23 @@ const Op = enum { } }; -// ---- the terminal scoreboard ---- -// -// The other axis pardes scales on, and the one a file table cannot see. A -// terminal pane's state is a ghostty-vt emulator with a 16 MiB scrollback, and -// three of the costs below walk ALL of it rather than the viewport: a COLUMN -// change reflows every page in the list (a row change does not — ghostty skips -// reflow when the width is unchanged, which is why both are here), and the -// motion surface is built from a dump of the whole history. -// -// The case this exists for is a coding agent printing a long transcript into a -// shell pane: `resize-cols` is what one frame of a window drag costs, and -// `key-down` is what one press of `j` costs afterwards. - const TermFixture = struct { name: []const u8, - /// KiB of pty output fed into the pane before any clock starts kb: usize, }; const term_fixtures = [_]TermFixture{ - // a shell you just opened: the control. Anything here is constant cost. .{ .name = "sb-64k", .kb = 64 }, - // a build log — also where the 1 MiB raw-byte replay ring fills up .{ .name = "sb-1m", .kb = 1024 }, - // an agent transcript: half the scrollback ceiling .{ .name = "sb-8m", .kb = 8 * 1024 }, }; const TermOp = enum { - /// one redraw, nothing changed — the floor the rest sit on render, - /// one 4 KiB pty read arrives: parse, replay ring, sync, frame output, - /// the window got one row shorter: resize WITHOUT reflow resize_rows, - /// the window got one column narrower: a full page-list reflow resize_cols, - /// `j` in the pane's body — builds the motion surface from the grid key_down, - /// one printable typed into the pane's edit buffer edit_char, fn label(o: TermOp) []const u8 { @@ -166,9 +91,6 @@ const TermOp = enum { } }; -/// One pty read's worth of output, built once and replayed by the `output` -/// row. Blocks are generated, not captured, for the same reason the file -/// fixtures are. var term_chunk: []const u8 = &.{}; const Cell = struct { @@ -178,67 +100,239 @@ const Cell = struct { max_us: u64 = 0, }; +const Allocations = struct { + backing: std.mem.Allocator = gpa, + account: ?*Allocations = null, + calls: usize = 0, + bytes: usize = 0, + live: usize = 0, + peak: usize = 0, + + fn allocator(self: *Allocations) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } }; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawAlloc(len, alignment, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live += len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) bool { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + if (!wrapper.backing.rawResize(memory, alignment, len, ret)) return false; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, len: usize, ret: usize) ?[*]u8 { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + const ptr = wrapper.backing.rawRemap(memory, alignment, len, ret) orelse return null; + self.calls += 1; + self.bytes += len; + self.live = self.live - memory.len + len; + self.peak = @max(self.peak, self.live); + return ptr; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret: usize) void { + const wrapper: *Allocations = @ptrCast(@alignCast(ctx)); + const self = wrapper.account orelse wrapper; + self.live -= memory.len; + wrapper.backing.rawFree(memory, alignment, ret); + } +}; + +fn measureCreation(io: std.Io, reps: usize, json: bool) !void { + if (reps == 0 or screen_cols == 0 or screen_rows == 0) return error.InvalidMeasurement; + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const deinit_samples = try gpa.alloc(u64, reps); + defer gpa.free(deinit_samples); + for ([_]bool{ false, true }) |terminal| { + if (terminal and !pardes.terminal_panes) continue; + var cold_ns: u64 = 0; + var cold_deinit_ns: u64 = 0; + var calls: usize = 0; + var bytes: usize = 0; + var live: usize = 0; + var peak: usize = 0; + for (0..reps + 1) |index| { + var counter: Allocations = .{}; + const allocator = counter.allocator(); + const begin = nowNs(); + const pane = if (terminal) + try pardes.panes.Terminal.create(allocator, screen_cols, screen_rows) + else + try pardes.panes.Terminal.createDoc(allocator, screen_cols, screen_rows); + const constructed = nowNs(); + const retained = counter.live; + pardes.panes.Terminal.deinitEmulator(pane, allocator); + allocator.destroy(pane); + const destroyed = nowNs(); + if (counter.live != 0) return error.LeakedPaneMemory; + if (index == 0) { + cold_ns = constructed - begin; + cold_deinit_ns = destroyed - constructed; + } else { + samples[index - 1] = constructed - begin; + deinit_samples[index - 1] = destroyed - constructed; + calls += counter.calls; + bytes += counter.bytes; + live += retained; + peak = @max(peak, counter.peak); + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, deinit_samples, {}, std.sort.asc(u64)); + const result = .{ + .build = build_identity, + .harness = harness_id, + .case = if (terminal) "create-terminal" else "create-document", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .pane_bytes = @sizeOf(pardes.Pane), + .cold_ns = cold_ns, + .median_ns = samples[reps / 2], + .p95_ns = samples[@min(reps - 1, reps * 95 / 100)], + .cold_deinit_ns = cold_deinit_ns, + .deinit_median_ns = deinit_samples[reps / 2], + .allocations = calls / reps, + .allocated_bytes = bytes / reps, + .live_bytes = live / reps, + .peak_bytes = peak, + .live_after_deinit = @as(usize, 0), + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "{s}: pane {d} B, {d} allocations / {d} B, retained {d} B, create {d} ns, deinit {d} ns, balanced", .{ + result.case, result.pane_bytes, result.allocations, result.allocated_bytes, result.live_bytes, result.median_ns, result.deinit_median_ns, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } +} + +const Options = struct { + json: bool = false, + reps: usize = 25, + cols: u16 = 120, + rows: u16 = 40, + base: ?[]const u8 = null, + only: ?[]const u8 = null, + creation: bool = false, + mini: bool = false, + terminal_mib: ?usize = null, + self_test: bool = false, + + fn parse(args: []const []const u8) !Options { + var opts: Options = .{}; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (!std.mem.startsWith(u8, arg, "--")) return error.UnknownOption; + const option = std.meta.stringToEnum(enum { json, creation, mini, @"terminal-mib", @"self-test", reps, cols, rows, base, only }, arg[2..]) orelse + return error.UnknownOption; + switch (option) { + .json => opts.json = true, + .creation => opts.creation = true, + .mini => opts.mini = true, + .@"self-test" => opts.self_test = true, + else => { + i += 1; + if (i == args.len) return error.MissingValue; + const value = args[i]; + switch (option) { + .reps => opts.reps = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + .cols => opts.cols = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .rows => opts.rows = std.fmt.parseInt(u16, value, 10) catch return error.InvalidNumber, + .base => opts.base = value, + .only => opts.only = value, + .@"terminal-mib" => opts.terminal_mib = std.fmt.parseInt(usize, value, 10) catch return error.InvalidNumber, + else => unreachable, + } + }, + } + } + if (opts.reps == 0 or opts.reps > std.math.maxInt(usize) / 100 or opts.cols == 0 or opts.rows == 0) + return error.InvalidMeasurement; + if (!opts.creation and (opts.cols < 8 or opts.rows < 5)) return error.InvalidMeasurement; + if (opts.terminal_mib) |mib| { + if (mib == 0 or mib > 256 or opts.cols < 32 or opts.cols > 512 or opts.rows < 8 or opts.rows > 256) + return error.InvalidMeasurement; + if (opts.creation or opts.only != null or opts.base != null) return error.IncompatibleOptions; + } + if (opts.base) |path| if (path.len == 0) return error.MissingValue; + if (opts.mini and (opts.creation or opts.terminal_mib != null or opts.only != null or opts.base != null)) + return error.IncompatibleOptions; + if (opts.only) |name| { + for (fixtures) |fixture| { + if (std.mem.eql(u8, name, fixture.name)) break; + } else return error.UnknownFixture; + } + if ((opts.creation and (opts.base != null or opts.only != null)) or + (opts.json and opts.base != null) or (opts.self_test and args.len != 1)) return error.IncompatibleOptions; + return opts; + } +}; + pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); - var json = false; - var reps: usize = 25; - var base_path: ?[]const u8 = null; - var only: ?[]const u8 = null; - var i: usize = 1; - while (i < args.len) : (i += 1) { - const a = args[i]; - if (std.mem.eql(u8, a, "--json")) { - json = true; - } else if (std.mem.eql(u8, a, "--reps") and i + 1 < args.len) { - i += 1; - reps = std.fmt.parseInt(usize, args[i], 10) catch reps; - } else if (std.mem.eql(u8, a, "--base") and i + 1 < args.len) { - i += 1; - base_path = args[i]; - } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) { - i += 1; - screen_cols = std.fmt.parseInt(u16, args[i], 10) catch screen_cols; - } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { - i += 1; - screen_rows = std.fmt.parseInt(u16, args[i], 10) catch screen_rows; - } else if (std.mem.eql(u8, a, "--only") and i + 1 < args.len) { - i += 1; - only = args[i]; - } else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json] [--cols N] [--rows N] [--only NAME]", .{}); - } - - // fixtures live in a temp dir and are rewritten every run: they are inputs - // to a measurement, and a stale one silently changes what the table means. - // .zig extensions on purpose — that is what puts tree-sitter in the frame. - const dir = "/tmp/pardes-perf"; - _ = libc.mkdir(dir, 0o755); // EEXIST is fine + const opts = try Options.parse(args[1..]); + if (opts.self_test) return selfTest(); + screen_cols = opts.cols; + screen_rows = opts.rows; + const reps = opts.reps; + const only = opts.only; + if (opts.creation) return measureCreation(init.io, reps, opts.json); + if (opts.terminal_mib) |mib| return measureTerminalSession(init.io, mib, reps, opts.json); + if (opts.mini) return measureMini(init.io, init.gpa, reps, opts.json); + + var random: [12]u8 = undefined; + init.io.random(&random); + const dir = try std.fmt.allocPrint(init.arena.allocator(), "/tmp/pardes-perf-{x}", .{random}); + try std.Io.Dir.createDirAbsolute(init.io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(init.io, dir) catch {}; var paths: [fixtures.len][]const u8 = undefined; var bytes: [fixtures.len]usize = undefined; + var created: usize = 0; + defer for (paths[0..created]) |path| gpa.free(path); for (fixtures, 0..) |fx, fi| { const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name }); + paths[fi] = path; + created += 1; const text = try generate(fx); + defer gpa.free(text); try writeFile(path, text); - paths[fi] = path; - // what got WRITTEN, not lines*cols: a line whose code is already wider - // than `cols` is left alone rather than truncated, so the nominal - // product would understate the file the editor actually opens bytes[fi] = text.len; - gpa.free(text); } + const base = if (opts.base) |path| blk: { + const source = try readFileAlloc(path); + defer gpa.free(source); + break :blk try parseBase(source, opts, &bytes); + } else null; - // `--only` leaves the other cells zeroed rather than reshaping the table. Its purpose is - // profiling, not reporting: under `perf record` a single 63 ms cell on the largest fixture - // swamps the samples, and the question "what does ONE keystroke on a small document spend its - // time in" cannot be answered from a profile dominated by a different one. var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { if (only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; - cells[oi][fi] = try measure(op, fx, paths[fi], reps); + cells[oi][fi] = try measure(op, fx, paths[fi], bytes[fi], reps); } } term_chunk = try buildTermText(4 * 1024); + defer gpa.free(term_chunk); var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { @@ -247,24 +341,16 @@ pub fn main(init: std.process.Init) !void { } } - if (json) return reportJson(init.io, &cells, &term_cells, &bytes, reps); - reportText(&cells, &term_cells, &bytes, reps, base_path); + if (opts.json) return reportJson(init.io, &cells, &term_cells, &bytes, opts); + reportText(&cells, &term_cells, &bytes, reps, opts.base, base); } -// ---- the measured gestures ---- - -/// One (op, fixture) cell: `reps` timed samples plus three warmup rounds, -/// reported as min / median / p90 / max. The spread between median and p90 is -/// the run's noise, and reportText prints the worst one so a reader knows how -/// big a difference has to be before it is real. -fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { +fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: usize) !Cell { const warmup = 3; const samples = try gpa.alloc(u64, reps); defer gpa.free(samples); if (op == .open) { - // fresh core per sample: opening is a one-shot, and the second Look at - // the same path only refocuses the pane already holding it. for (0..warmup + reps) |n| { const core = try boot(); defer core.deinit(); @@ -273,6 +359,9 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + const id = filePane(core) orelse return error.OpenDidNotOpenFile; + const file = &core.panes[id].?.file.?; + if (!std.mem.eql(u8, file.path, path) or file.content.len != expected_bytes) return error.WrongOpenedFile; if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); @@ -282,13 +371,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { defer core.deinit(); core.lookAt(0, path); pump(core); - const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path}); + const id = filePane(core) orelse return error.OpenDidNotOpenFile; core.active = id; const pane = core.panes[id].?; _ = try frame(core); - // insert mode is entered ONCE: `i` is a mode change, not a keystroke of - // typing, and measuring it inside every edit sample would report the wrong - // thing entirely. if (op == .edit_char) { core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); pump(core); @@ -297,6 +383,10 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { for (0..warmup + reps) |n| { seek(pane, fx, n); _ = try frame(core); // settle the view at the new spot, untimed + const before = .{ .row = pane.cur_row, .col = pane.cur_col, .scroll = pane.scroll(), .len = pane.file.?.content.len }; + const rect = core.rects[id]; + const wheel_col = rect.x + pardes.config.GUTTER; + const wheel_row = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; const t0 = nowNs(); switch (op) { .open => unreachable, @@ -304,21 +394,34 @@ fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell { .key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }), .key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }), .page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }), - .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = 4, .row = 8 } }), + .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }), .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), } pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .open => unreachable, + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or pane.file.?.content.len != before.len) + return error.RenderChangedDocument, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .key_right => if (pane.cur_row != before.row or pane.cur_col != before.col + 1) return error.RightDidNotMove, + .page_down => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.PageDidNotScroll, + .wheel => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows) + return error.WheelDidNotScroll, + .edit_char => { + if (pane.file.?.content.len != before.len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.panes.File.sourceLine(pane, before.row); + if (line[@intCast(before.col)] != 'x') return error.CharacterWasNotInserted; + }, + } if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); } -/// One (op, fixture) cell of the terminal table. Same contract as `measure` — -/// fresh core per cell, warmups, median of `reps` — with no `seek`: a terminal -/// has one cursor, and everything measured here scales with the size of the -/// HISTORY rather than with where in it you are standing. fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { const warmup = 3; const samples = try gpa.alloc(u64, reps); @@ -326,26 +429,42 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { const core = try bootTerm(fx); defer core.deinit(); - const pane = core.panes[0] orelse fatal("terminal boot produced no pane", .{}); - // tty mode hands every key straight to the shell, so the two gesture rows - // would otherwise measure one queued write effect and nothing else + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; if (op == .key_down or op == .edit_char) pane.mode = .normal; if (op == .edit_char) { core.update(.{ .key = .{ .cp = 'i', .text = "i" } }); pump(core); } _ = try frame(core); + const motion_rows = if (op == .key_down) (try core.paneCursorLines(pane)).len else 0; + if (op == .key_down and motion_rows < 3) return error.EmptyTerminalFixture; var cols = screen_cols; var rows = screen_rows; for (0..warmup + reps) |n| { + if (op == .key_down) { + const span = @max(1, motion_rows * 8 / 10); + pane.cur_row = @intCast(@min(motion_rows - 2, motion_rows / 10 + (n *% 7919) % span)); + pane.cur_col = 0; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + _ = try frame(core); + } + const before = .{ + .row = pane.cur_row, + .col = pane.cur_col, + .edit_len = if (pane.ovl) |overlay| overlay.text.len else 0, + .replay_head = state.replay.head, + .replay_len = state.replay.len, + .cols = pane.cols, + .rows = pane.rows, + }; const t0 = nowNs(); switch (op) { .render => {}, .output => core.update(.{ .output = .{ .pane = 0, .bytes = term_chunk } }), - // one cell of window drag. Alternating rather than sweeping so - // every sample does a real resize and the fixture never drifts - // away from the size the other rows are measured at. .resize_rows => { rows = if (rows == screen_rows) screen_rows - 1 else screen_rows; core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); @@ -360,16 +479,45 @@ fn measureTerm(op: TermOp, fx: TermFixture, reps: usize) !Cell { pump(core); _ = try frame(core); const dt = nowNs() -| t0; + switch (op) { + .render => if (pane.cur_row != before.row or pane.cur_col != before.col or + state.replay.head != before.replay_head or state.replay.len != before.replay_len) + return error.RenderChangedTerminal, + .output => { + const capacity = state.replay.bytes.len; + if (term_chunk.len == 0 or term_chunk.len >= capacity) return error.InvalidOutputFixture; + const overflow = term_chunk.len -| (capacity - before.replay_len); + if (state.replay.len != @min(capacity, before.replay_len + term_chunk.len) or + state.replay.head != (before.replay_head + overflow) % capacity) + return error.OutputWasNotRecorded; + const start = (state.replay.head + state.replay.len - term_chunk.len) % capacity; + const first = @min(term_chunk.len, capacity - start); + if (!std.mem.eql(u8, term_chunk[0..first], state.replay.bytes[start..][0..first]) or + !std.mem.eql(u8, term_chunk[first..], state.replay.bytes[0 .. term_chunk.len - first])) + return error.WrongTerminalOutput; + }, + .resize_rows => if (pane.rows == before.rows or pane.cols != before.cols or + state.vt.screens.active.pages.rows != pane.rows) return error.TerminalDidNotResize, + .resize_cols => if (pane.cols == before.cols or pane.rows != before.rows or + state.vt.screens.active.pages.cols != pane.cols) return error.TerminalDidNotResize, + .key_down => if (pane.cur_row != before.row + 1) return error.DownDidNotMove, + .edit_char => { + const overlay = pane.ovl orelse return error.CharacterWasNotInserted; + if (overlay.text.len != before.edit_len + 1 or pane.cur_row != before.row or pane.cur_col != before.col + 1) + return error.CharacterWasNotInserted; + const line = pardes.modal.lineSlice(overlay.text, @intCast(pane.cur_row - overlay.row)); + if (before.col < 0 or before.col >= line.len or line[@intCast(before.col)] != 'x') + return error.CharacterWasNotInserted; + }, + } if (n >= warmup) samples[n - warmup] = dt / 1000; } return summarize(samples); } -/// A shell pane carrying `fx.kb` KiB of history. The bytes go in as 8 KiB -/// reads, which is both what a pty delivers and what the flood costs: one -/// core update per chunk. fn bootTerm(fx: TermFixture) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + errdefer core.deinit(); core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } }); pump(core); const text = try buildTermText(fx.kb * 1024); @@ -384,10 +532,6 @@ fn bootTerm(fx: TermFixture) !*pardes.Pardes { return core; } -/// Plausible pty output, at least `want` bytes of it: an OSC 133-marked -/// prompt, the command, and a run of coloured result lines. The prompt markers -/// are the point — the motion surface blanks prompt rows, so a history without -/// them measures a branch no real shell ever takes. fn buildTermText(want: usize) ![]const u8 { var out: std.Io.Writer.Allocating = .init(gpa); errdefer out.deinit(); @@ -407,14 +551,505 @@ fn buildTermText(want: usize) ![]const u8 { return out.toOwnedSlice(); } -/// Park the cursor and the view at sample `n`'s position, walked across the -/// file by a prime stride so consecutive samples land nowhere near each other -/// and a whole run covers the document rather than one neighbourhood of it. -/// Rows sit in the middle 80% (a position at the very top or bottom measures -/// the clamp, not the work), and on a wide fixture the column is deep inside -/// the line so `key-right` exercises the hscroll cut rather than column 1. -/// The sequence depends only on `n`, so two builds see the same positions in -/// the same order and their cells are comparable one for one. +fn terminalBatch(buffer: []u8, batch: usize, cols: u16) ![]const u8 { + var out: std.Io.Writer = .fixed(buffer); + try out.writeAll("\r\x1b[2K"); + var record: usize = 0; + while (out.end < 8192) : (record += 1) { + try out.print("\x1b[36mturn {d} tool {d}\x1b[0m reading src/example.zig\r\n", .{ batch, record }); + try out.writeAll("\x1b[90m reviewing: \x1b[0m"); + for (0..@as(usize, cols) * 3 / 16 + 1) |_| try out.writeAll("text λ界 result "); + try out.writeAll("\r\n\x1b[33mprogress 20%\x1b[0m\r\x1b[2K\x1b[32mprogress 100% ok\x1b[0m\r\n"); + } + try out.print("\x1b[32mTURN {d:0>10} READY\x1b[0m", .{batch}); + return out.buffered(); +} + +fn checkTerminalTail(core: *pardes.Pardes, surface: *pardes.Surface, batch: usize) !void { + 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; + 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| { + const cell = surface.at(x + @as(u16, @intCast(col)), y); + if (!std.mem.eql(u8, cell.grapheme(), &.{byte})) return error.LatestTerminalTailMissing; + if (!std.meta.eql(pardes.Color{ .index = 2 }, cell.style.fg)) return error.LatestTerminalTailStyleWrong; + } +} + +fn processPeakRss() ?usize { + const os = @import("builtin").os.tag; + if (comptime os != .linux and !os.isDarwin()) return null; + if (comptime os == .linux) { + const fd = libc.open("/proc/self/status", .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + var buffer: [8192]u8 = undefined; + const n = libc.read(fd, &buffer, buffer.len); + if (n <= 0) return null; + var lines = std.mem.splitScalar(u8, buffer[0..@intCast(n)], '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "VmHWM:")) continue; + var words = std.mem.tokenizeAny(u8, line[6..], " \t"); + const kib = std.fmt.parseInt(usize, words.next() orelse return null, 10) catch return null; + return std.math.mul(usize, kib, 1024) catch null; + } + return null; + } + var usage: libc.rusage = undefined; + if (libc.getrusage(libc.rusage.SELF, &usage) != 0 or usage.maxrss < 0) return null; + return @intCast(usage.maxrss); +} + +const UndoOutput = struct { + depth: usize, + input_bytes: usize, + updates: usize, + history_bytes: usize, + update_median_ns: u64, + update_p95_ns: u64, + batch_median_ns: u64, + batch_p95_ns: u64, + allocator_calls: usize, + allocator_live_before: usize, + allocator_live_after: usize, + anchor_evicted: bool, +}; + +fn measureUndoOutput(core: *pardes.Pardes, counter: *Allocations, arena: *std.heap.ArenaAllocator, samples: []u64) ![3]UndoOutput { + const pane = core.panes[0].?; + const pages = &pane.terminal.?.vt.screens.active.pages; + if (pane.ed_undo_len != 0 or pane.ed_redo_len != 0) return error.UnexpectedTerminalHistory; + if (pane.ovl) |overlay| core.gpa.free(overlay.text); + pane.ovl = null; + var result: [3]UndoOutput = undefined; + const updates = try gpa.alloc(u64, samples.len * 4); + defer gpa.free(updates); + var buffer: [16 * 1024]u8 = undefined; + for ([_]usize{ 0, 1, 64 }, 0..) |depth, phase| { + const first: i32 = @intCast(@min(128, pages.total_rows - 2)); + for (0..depth) |index| { + pane.ovl = .{ .row = first, .rows = 2, .text = try std.fmt.allocPrint(core.gpa, "saved edit {d}\nsecond row", .{index}) }; + pane.cur_row = first + 1; + pane.cur_col = 1; + pane.vsel = .{ .active = true, .row = first, .col = 0 }; + pardes.panes.Terminal.pushUndo(core, pane); + core.gpa.free(pane.ovl.?.text); + pane.ovl = null; + if (pane.ed_undo_len != index + 1) return error.TerminalUndoSnapshotFailed; + } + defer { + for (pane.ed_undo[0..pane.ed_undo_len]) |snapshot| if (snapshot.ovl) |overlay| core.gpa.free(overlay.text); + pane.ed_undo_len = 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) } }).?); + defer pages.untrackPin(start); + const end = try pages.trackPin(pages.pin(.{ .screen = .{ .y = @intCast(first + 1) } }).?); + defer pages.untrackPin(end); + const calls_before = counter.calls; + const live_before = counter.live; + const history_bytes = pages.page_size; + var sent: usize = 0; + var nupdates: usize = 0; + for (samples, 0..) |*sample, batch| { + const bytes = try terminalBatch(&buffer, batch, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const stop = @min(offset + 4096, bytes.len); + const update_begin = nowNs(); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..stop] } }); + pump(core); + updates[nupdates] = nowNs() - update_begin; + nupdates += 1; + offset = stop; + } + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + sent += bytes.len; + try checkTerminalTail(core, surface, batch); + if (pages.countTrackedPins() != tracked_before + 2) return error.TerminalPinsLeaked; + const expected: i32 = if (start.garbage) 0 else @intCast((pages.pointFromPin(.screen, start.*) orelse return error.TerminalAnchorLost).screen.y); + const last: i32 = if (end.garbage) 0 else @intCast((pages.pointFromPin(.screen, end.*) orelse return error.TerminalAnchorLost).screen.y); + for (pane.ed_undo[0..pane.ed_undo_len], 0..) |snapshot, index| { + const overlay = snapshot.ovl orelse return error.TerminalUndoTextLost; + var expected_buffer: [64]u8 = undefined; + const text = try std.fmt.bufPrint(&expected_buffer, "saved edit {d}\nsecond row", .{index}); + if (!std.mem.eql(u8, text, overlay.text)) return error.TerminalUndoTextChanged; + if (overlay.row != expected or overlay.rows != @max(1, last - expected + 1) or + snapshot.cur_row != expected + 1 or snapshot.cur_col != 1 or + !snapshot.vsel.active or snapshot.vsel.row != expected or snapshot.vsel.col != 0) + return error.TerminalUndoAnchorWrong; + } + } + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + std.mem.sort(u64, updates[0..nupdates], {}, std.sort.asc(u64)); + result[phase] = .{ + .depth = depth, + .input_bytes = sent, + .updates = nupdates, + .history_bytes = history_bytes, + .update_median_ns = updates[nupdates / 2], + .update_p95_ns = updates[(nupdates * 95 + 99) / 100 - 1], + .batch_median_ns = samples[samples.len / 2], + .batch_p95_ns = samples[(samples.len * 95 + 99) / 100 - 1], + .allocator_calls = counter.calls - calls_before, + .allocator_live_before = live_before, + .allocator_live_after = counter.live, + .anchor_evicted = start.garbage, + }; + } + return result; +} + +fn measureTerminalSession(io: std.Io, mib: usize, reps: usize, json: bool) !void { + if (comptime !pardes.terminal_panes) return error.TerminalsUnavailable; + const requested_bytes = mib * 1024 * 1024; + const samples = try gpa.alloc(u64, requested_bytes / 8192 + 1); + defer gpa.free(samples); + const modal_samples = try gpa.alloc(u64, reps); + defer gpa.free(modal_samples); + var counter: Allocations = .{}; + const result = blk: { + const core = try pardes.Pardes.init(counter.allocator(), .{ .tty_only = true, .cols = screen_cols, .rows = screen_rows }); + defer core.deinit(); + pump(core); + const pane = core.panes[0] orelse return error.MissingTerminal; + const state = pane.terminal orelse return error.MissingTerminal; + pane.mode = .tty; + pane.tty_filter = false; + var arena: std.heap.ArenaAllocator = .init(counter.allocator()); + defer arena.deinit(); + _ = try core.render(arena.allocator()); + var buffer: [16 * 1024]u8 = undefined; + var sent: usize = 0; + var batches: usize = 0; + var update_render_ns: u64 = 0; + var row_count_decreases: usize = 0; + var previous_rows: usize = 0; + const stream_begin = nowNs(); + while (sent < requested_bytes) : (batches += 1) { + const bytes = try terminalBatch(&buffer, batches, pane.cols); + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + var offset: usize = 0; + while (offset < bytes.len) { + const end = @min(offset + 4096, bytes.len); + core.update(.{ .output = .{ .pane = 0, .bytes = bytes[offset..end] } }); + pump(core); + offset = end; + } + const surface = try core.render(arena.allocator()); + samples[batches] = nowNs() - begin; + update_render_ns += samples[batches]; + try checkTerminalTail(core, surface, batches); + const scrollbar = state.vt.screens.active.pages.scrollbar(); + if (scrollbar.offset != scrollbar.total - pane.rows) return error.TerminalStoppedFollowing; + if (scrollbar.total < previous_rows) row_count_decreases += 1; + previous_rows = scrollbar.total; + sent += bytes.len; + } + 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); + _ = arena.reset(.retain_capacity); + const modal_begin = nowNs(); + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + core.update(.{ .key = .{ .cp = 'k', .text = "k" } }); + pump(core); + _ = try core.render(arena.allocator()); + const first_modal_ns = nowNs() - modal_begin; + if (pane.mode != .normal or !pane.cur_pinned or pane.cur_row != first_row - 1) return error.FirstModalKeyDidNotMove; + for (modal_samples, 0..) |*sample, index| { + const down = index % 2 == 0; + const before = pane.cur_row; + _ = arena.reset(.retain_capacity); + const begin = nowNs(); + core.update(.{ .key = .{ .cp = if (down) 'j' else 'k' } }); + pump(core); + _ = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + if (pane.cur_row != before + @as(i32, if (down) 1 else -1)) return error.ModalKeyDidNotMove; + } + core.update(.{ .key = .{ .cp = core.opts.tty_toggle, .ctrl = true } }); + pump(core); + _ = arena.reset(.retain_capacity); + try checkTerminalTail(core, try core.render(arena.allocator()), batches - 1); + core.update(.{ .key = .{ .cp = 'f', .text = "f" } }); + const input = core.nextEffect() orelse return error.TerminalInputMissing; + if (input != .write or input.write.pane != 0 or !std.mem.eql(u8, "f", input.write.bytes.slice())) + return error.TerminalInputWrong; + if (core.nextEffect() != null) return error.UnexpectedTerminalEffect; + std.mem.sort(u64, samples[0..batches], {}, std.sort.asc(u64)); + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + const modal_median_ns = modal_samples[reps / 2]; + const modal_p95_ns = modal_samples[(reps * 95 + 99) / 100 - 1]; + const live_after_modal = counter.live; + var overlay_median_ns: [2]u64 = undefined; + var overlay_p95_ns: [2]u64 = undefined; + pane.mode = .normal; + pane.cur_pinned = true; + pane.ovl = .{ .row = 0, .rows = 1, .text = try core.gpa.dupe(u8, "edited output") }; + for (0..2) |phase| { + for (modal_samples, 0..) |*sample, index| { + const tail_id = batches + phase * reps + index; + var update_buffer: [64]u8 = undefined; + const update = try std.fmt.bufPrint(&update_buffer, "\r\n\x1b[32mTURN {d:0>10} READY\x1b[0m", .{tail_id}); + _ = arena.reset(.retain_capacity); + 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); + pane.ovl.?.row = if (phase == 0) 0 else cursor_row - 1; + pane.cur_row = pane.ovl.?.row; + pane.cur_col = 0; + const surface = try core.render(arena.allocator()); + sample.* = nowNs() - begin; + try checkTerminalTail(core, surface, tail_id); + } + std.mem.sort(u64, modal_samples, {}, std.sort.asc(u64)); + overlay_median_ns[phase] = modal_samples[reps / 2]; + overlay_p95_ns[phase] = modal_samples[(reps * 95 + 99) / 100 - 1]; + } + const live_after_overlay = counter.live; + const undo_output = try measureUndoOutput(core, &counter, &arena, modal_samples); + break :blk .{ + .build = build_identity, + .harness = harness_id, + .case = "terminal-session", + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .requested_bytes = requested_bytes, + .input_bytes = sent, + .batches = batches, + .update_render_ns = update_render_ns, + .stream_wall_ns = stream_wall_ns, + .mib_per_second = @as(f64, @floatFromInt(sent)) * 1_000_000_000 / 1048576 / @as(f64, @floatFromInt(@max(1, update_render_ns))), + .batch_median_ns = samples[batches / 2], + .batch_p95_ns = samples[(batches * 95 + 99) / 100 - 1], + .batch_max_ns = samples[batches - 1], + .first_modal_ns = first_modal_ns, + .modal_median_ns = modal_median_ns, + .modal_p95_ns = modal_p95_ns, + .offscreen_overlay_median_ns = overlay_median_ns[0], + .offscreen_overlay_p95_ns = overlay_p95_ns[0], + .visible_overlay_median_ns = overlay_median_ns[1], + .visible_overlay_p95_ns = overlay_p95_ns[1], + .undo_output = undo_output, + .history_rows = state.vt.screens.active.pages.scrollbar().total, + .history_bytes = state.vt.screens.active.pages.page_size, + .history_limit_bytes = state.vt.screens.active.pages.maxSize(), + .row_count_decreases = row_count_decreases, + .allocator_calls = counter.calls, + .allocator_live_after_output = live_after_output, + .allocator_live_after_modal = live_after_modal, + .allocator_live_after_overlay = live_after_overlay, + .allocator_peak_bytes = counter.peak, + .allocator_live_after_deinit = @as(usize, 0), + .process_peak_rss_bytes = processPeakRss(), + .process_rss_scope = "current image VmHWM on Linux; process rusage on Darwin", + .allocator_scope = "core and frame allocator; excludes VT page mappings", + }; + }; + if (counter.live != 0) return error.LeakedSessionMemory; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, result, .{}) + else + try std.fmt.allocPrint(gpa, "terminal session: {d} bytes, {d} batches, {d:.2} MiB/s; p95 {d} ns, first modal {d} ns; history {d} rows / {d} B, allocator peak {d} B (excludes VT mappings), process peak {?d} B", .{ + result.input_bytes, result.batches, result.mib_per_second, result.batch_p95_ns, result.first_modal_ns, + result.history_rows, result.history_bytes, result.allocator_peak_bytes, result.process_peak_rss_bytes, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); +} + +const MiniProbe = enum { generate, highlight_generate, render }; +const MiniMeasurement = struct { + cold_ns: u64 = 0, + median_ns: u64 = 0, + p95_ns: u64 = 0, + allocations_total: usize = 0, + allocated_bytes_total: usize = 0, + warm_allocations_total: usize = 0, + warm_allocated_bytes_total: usize = 0, + peak_bytes: usize = 0, + live_after_warm: usize = 0, + live_after_deinit: usize = 0, +}; + +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 { + const samples = try gpa.alloc(u64, reps); + defer gpa.free(samples); + const allocators = pardes.memory.init(fallback); + defer pardes.memory.deinit(); + var counter: Allocations = .{}; + var core_counter: Allocations = .{ .backing = allocators.pardes, .account = &counter }; + var syntax_counter: Allocations = .{ .backing = allocators.tree_sitter, .account = &counter }; + var frame_counter: Allocations = .{ .backing = allocators.frame, .account = &counter }; + const allocator = core_counter.allocator(); + const syntax_allocator = syntax_counter.allocator(); + const frame_allocator = frame_counter.allocator(); + var result: MiniMeasurement = .{}; + { + if (probe != .generate) pardes.syntax.start(syntax_allocator); + defer if (probe != .generate) pardes.syntax.stop(); + const core = if (probe == .render) + try pardes.Pardes.init(allocator, .{ + .tty_only = true, + .cols = screen_cols, + .rows = screen_rows, + .tree_sitter_allocator = syntax_allocator, + .frame_allocator = frame_allocator, + }) + else + null; + defer if (core) |p| p.deinit(); + var arena: std.heap.ArenaAllocator = .init(frame_allocator); + defer arena.deinit(); + if (core) |p| { + pump(p); + 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); + pump(p); + p.settings.colors = true; + const file = p.panes[p.active].?.file.?; + try checkMini(.{ .content = file.content, .colors = file.mini.?.colors }, expected); + } + var frame_hash: ?u64 = null; + for (0..reps + 1) |index| { + _ = arena.reset(.retain_capacity); + const calls = counter.calls; + const bytes = counter.bytes; + const begin = nowNs(); + if (core) |p| { + const surface = try p.render(arena.allocator()); + const elapsed = nowNs() - begin; + if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; + var hash: std.hash.Wyhash = .init(0); + var braille_cells: usize = 0; + for (surface.cells) |cell| { + const glyph = cell.grapheme(); + hash.update(glyph); + std.hash.autoHash(&hash, cell.style); + if (glyph.len == 3 and glyph[0] == 0xe2 and glyph[1] >= 0xa0 and glyph[1] <= 0xa3) + braille_cells += 1; + } + if (braille_cells == 0) return error.MiniNotRendered; + if (frame_hash) |old| { + if (old != hash.final()) return error.MiniRenderChanged; + } else frame_hash = hash.final(); + } else { + const highlighted = if (probe == .highlight_generate) + try pardes.syntax.highlightFileRange(syntax_allocator, path, source, 0, source.len) + else + styles; + defer if (probe == .highlight_generate) syntax_allocator.free(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; + if (!std.mem.eql(u8, highlighted, styles)) return error.MiniHighlightMismatch; + try checkMini(converted, expected); + } + if (index != 0) { + result.warm_allocations_total += counter.calls - calls; + result.warm_allocated_bytes_total += counter.bytes - bytes; + } + } + result.live_after_warm = counter.live; + } + if (counter.live != 0) return error.LeakedMiniMemory; + std.mem.sort(u64, samples, {}, std.sort.asc(u64)); + result.median_ns = samples[reps / 2]; + result.p95_ns = samples[(reps * 95 + 99) / 100 - 1]; + result.allocations_total = counter.calls; + result.allocated_bytes_total = counter.bytes; + result.peak_bytes = counter.peak; + result.live_after_deinit = counter.live; + return result; +} + +fn measureMini(io: std.Io, fallback: std.mem.Allocator, reps: usize, json: bool) !void { + var random: [12]u8 = undefined; + io.random(&random); + const dir = try std.fmt.allocPrint(gpa, "/tmp/pardes-perf-mini-{x}", .{random}); + defer gpa.free(dir); + try std.Io.Dir.createDirAbsolute(io, dir, .default_dir); + defer std.Io.Dir.cwd().deleteTree(io, dir) catch {}; + for ([_]Fixture{ + .{ .name = "mini-small", .lines = 256, .cols = 72 }, + .{ .name = "mini-1m", .lines = 14_400, .cols = 72 }, + .{ .name = "mini-longline", .lines = 1, .cols = 1024 * 1024 }, + .{ .name = "mini-unicode-tabs", .lines = 4096, .cols = 0 }, + }) |fixture| { + const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fixture.name }); + defer gpa.free(path); + const unicode_line = "\tconst label = \"界 café 🙂\";\n\t// é 漢字\tcomment\n"; + const source = if (fixture.cols == 0) blk: { + const bytes = try gpa.alloc(u8, unicode_line.len * fixture.lines); + for (0..fixture.lines) |i| @memcpy(bytes[i * unicode_line.len ..][0..unicode_line.len], unicode_line); + break :blk bytes; + } else try generate(fixture); + defer gpa.free(source); + try writeFile(path, source); + const styles = blk: { + pardes.syntax.start(gpa); + defer pardes.syntax.stop(); + break :blk try pardes.syntax.highlightFileRange(gpa, path, source, 0, source.len); + }; + 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); + 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; + for (std.enums.values(MiniProbe)) |probe| { + const measurement = try miniMeasurement(fallback, probe, path, source, styles, expected, reps); + const row = .{ + .build = build_identity, + .harness = harness_id, + .case = fixture.name, + .probe = @tagName(probe), + .cols = screen_cols, + .rows = screen_rows, + .reps = reps, + .input_bytes = source.len, + .input_hash = std.hash.Wyhash.hash(0, source), + .input_style_hash = std.hash.Wyhash.hash(0, styles), + .output_bytes = expected.content.len, + .output_hash = std.hash.Wyhash.hash(0, expected.content), + .output_style_hash = std.hash.Wyhash.hash(0, expected.colors), + .measurement = measurement, + .allocator = "native memory.init with process allocator fallback; core, syntax and frame pools reset per probe", + .allocation_scope = "requested probe allocations, including render setup; excludes fixed pool storage, VT mappings, fixture, reference, samples and reporting", + }; + const text = if (json) + try std.json.Stringify.valueAlloc(gpa, row, .{}) + else + try std.fmt.allocPrint(gpa, "{s} {s}: {d} -> {d} bytes, median {d} ns, peak {d} B, warm allocations {d}/{d} samples, balanced", .{ + row.case, row.probe, row.input_bytes, row.output_bytes, measurement.median_ns, measurement.peak_bytes, measurement.warm_allocations_total, reps, + }); + defer gpa.free(text); + try std.Io.File.stdout().writeStreamingAll(io, text); + try std.Io.File.stdout().writeStreamingAll(io, "\n"); + } + } +} + fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void { const f = &pane.file.?; const span = @max(1, fx.lines * 8 / 10); @@ -435,13 +1070,10 @@ fn boot() !*pardes.Pardes { return core; } -/// one frame into a throwaway arena — the shell's per-frame arena, which is -/// what keeps the retained-render contract honest (vaxis stores slices into -/// whatever it was handed, so the frame's text must outlive vx.render) -fn frame(core: *pardes.Pardes) !*pardes.Surface { +fn frame(core: *pardes.Pardes) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); - return core.render(arena.allocator()); + _ = try core.render(arena.allocator()); } fn filePane(core: *pardes.Pardes) ?usize { @@ -452,8 +1084,6 @@ fn filePane(core: *pardes.Pardes) ?usize { return null; } -/// Drain queued effects, all ignored (spawn, write, watch, ...): there is no -/// shell here, and nothing measured depends on one answering. fn pump(core: *pardes.Pardes) void { while (core.nextEffect()) |_| {} } @@ -464,18 +1094,11 @@ fn summarize(samples: []u64) Cell { return .{ .min_us = samples[0], .med_us = samples[samples.len / 2], - .p90_us = samples[(samples.len * 9) / 10 -| 1], + .p90_us = samples[(samples.len * 9 + 9) / 10 - 1], .max_us = samples[samples.len - 1], }; } -// ---- the fixture generator ---- - -/// Plausible Zig, so the tree-sitter pass has real nodes to walk rather than -/// one giant error node: a repeating four-line shape padded to `cols`. Long -/// lines get their width from a comment tail — the alternative (an enormous -/// string literal) makes the whole file one token and flatters every scan that -/// looks for a newline. fn generatedPrefixLen(n: usize) usize { return switch (n % 4) { 0 => std.fmt.count("const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }), @@ -528,17 +1151,15 @@ fn writeFile(path: []const u8, text: []const u8) !void { } } -// ---- reporting ---- - fn reportText( cells: *const [std.enums.values(Op).len][fixtures.len]Cell, term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize, base_path: ?[]const u8, + base: ?Base, ) void { const o = std.debug.print; - const base = if (base_path) |bp| readBase(bp, reps) else null; o("pardes perf — {d}x{d} viewport, {d} samples/cell, median us\n\n", .{ screen_cols, screen_rows, reps }); o("{s:<12}", .{"fixture"}); @@ -551,14 +1172,10 @@ fn reportText( for (bytes) |b| o(" {d:>10} KB", .{b / 1024}); o("\n\n", .{}); - // one row per gesture. `open` is a whole Look; every other row is - // update+effects+one frame, which is the latency a user actually sees. o("{s:<12}", .{"op"}); for (fixtures) |fx| { if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); } - // 12 for the op name + one column per fixture, wider when a baseline adds - // its ratio to each cell o("\n{s}\n", .{if (base != null) "-" ** (12 + fixtures.len * 20) else "-" ** (12 + fixtures.len * 13)}); var worst_jitter: f64 = 0; for (std.enums.values(Op), 0..) |op, oi| { @@ -582,7 +1199,6 @@ fn reportText( o("\n", .{}); } - // second table, same shape: the terminal costs, against SCROLLBACK o("\n{s:<12}", .{"scrollback"}); for (term_fixtures) |fx| { if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name}); @@ -608,20 +1224,12 @@ fn reportText( } o("\n", .{}); } - // The spread is not only scheduler noise: samples are taken at DIFFERENT - // places in the file on purpose (see seek), so a cost that still depends on - // where the cursor is shows up here as well. Both are reasons not to - // believe a small difference, and the sample positions are identical from - // run to run, so two builds are still comparable cell for cell. o("\nnoise: worst cell p90 is {d:.0}% over its median (scheduler + the spread\n", .{worst_jitter * 100}); o("of sample positions through the file). Treat a difference smaller than\n", .{}); o("that as nothing, and re-run before believing a small win.\n", .{}); if (base_path) |bp| o("baseline: {s} (x column = now / then; under 1.00 is faster)\n", .{bp}); } -/// Real stdout, not std.debug.print's stderr: this is the form `--base` reads -/// back, and `zig build perf -- --json > runs/old.json` writing an empty file -/// would make the next comparison silently print no ratios at all. const json_report_max_bytes = 32 * 1024; fn reportJson( @@ -629,38 +1237,53 @@ fn reportJson( cells: *const [std.enums.values(Op).len][fixtures.len]Cell, term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, bytes: *const [fixtures.len]usize, - reps: usize, -) void { + opts: Options, +) !void { var storage: [json_report_max_bytes]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); - out.print("{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return; + try writeJson(&out, cells, term_cells, bytes, opts); + try std.Io.File.stdout().writeStreamingAll(io, out.buffered()); +} + +fn writeJson( + out: *std.Io.Writer, + cells: *const [std.enums.values(Op).len][fixtures.len]Cell, + term_cells: *const [std.enums.values(TermOp).len][term_fixtures.len]Cell, + bytes: *const [fixtures.len]usize, + opts: Options, +) !void { + try out.print("{{\"harness\":\"{s}\",\"build\":", .{harness_id}); + try std.json.Stringify.value(build_identity, .{}, out); + try out.print(",\"cols\":{d},\"rows\":{d},\"reps\":{d},\"only\":", .{ opts.cols, opts.rows, opts.reps }); + try std.json.Stringify.value(opts.only, .{}, out); + try out.writeAll(",\"fixtures\":["); for (fixtures, 0..) |fx, fi| { - out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ + try out.print("{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{ if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi], - }) catch return; + }); } - out.writeAll("],\"cells\":[") catch return; + try out.writeAll("],\"cells\":["); var first = true; for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; const c = cells[oi][fi]; - out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + try out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, - }) catch return; + }); first = false; } } - for (std.enums.values(TermOp), 0..) |op, oi| { + if (opts.only == null) for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { const c = term_cells[oi][fi]; - out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ + try out.print("{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us, - }) catch return; + }); first = false; } - } - out.writeAll("]}\n") catch return; - std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; + }; + try out.writeAll("]}\n"); } const Base = struct { @@ -668,50 +1291,242 @@ const Base = struct { term_med: [std.enums.values(TermOp).len][term_fixtures.len]u64, }; -/// A previous --json run, reduced to the medians this table compares against. -/// Cells the old run did not have stay 0 and print as "-": the op list may -/// have grown since, and a missing number is not a regression. An unreadable -/// file is fatal rather than silently ratio-less — a comparison you asked for -/// and did not get is worse than no comparison. -/// -/// So is one that is quietly WRONG, which is why `reps` has to match. `seek` -/// walks sample n to `n *% 7919 % span`, so two runs with different counts -/// measure different PLACES in the file, and every file row comes out 20-50% -/// apart with nothing whatever having changed. -fn readBase(path: []const u8, reps: usize) ?Base { - const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path}); - defer gpa.free(src); - var b: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; - const parsed = std.json.parseFromSlice(struct { - reps: usize = 0, - cells: []const struct { - op: []const u8, - fixture: []const u8, - med_us: u64, - }, - }, gpa, src, .{ .ignore_unknown_fields = true }) catch return null; +const Report = struct { + harness: []const u8 = "", + build: BuildIdentity, + cols: u16, + rows: u16, + reps: usize, + only: ?[]const u8 = null, + fixtures: []const struct { name: []const u8, lines: usize, cols: usize, bytes: usize }, + cells: []const struct { + op: []const u8, + fixture: []const u8, + min_us: u64, + med_us: u64, + p90_us: u64, + max_us: u64, + }, +}; + +fn parseBase(src: []const u8, opts: Options, bytes: *const [fixtures.len]usize) !Base { + const parsed = std.json.parseFromSlice(Report, gpa, src, .{}) catch return error.InvalidBaseline; defer parsed.deinit(); - if (parsed.value.reps != reps) fatal( - "--base: {s} was taken with --reps {d}, this run is --reps {d}. Same count or no comparison.", - .{ path, parsed.value.reps, reps }, - ); - for (parsed.value.cells) |c| { - for (std.enums.values(Op), 0..) |op, oi| { - if (!std.mem.eql(u8, op.label(), c.op)) continue; - for (fixtures, 0..) |fx, fi| { - if (std.mem.eql(u8, fx.name, c.fixture)) b.med[oi][fi] = c.med_us; - } + const report = parsed.value; + if (!std.mem.eql(u8, report.harness, harness_id)) return error.BaselineHarnessMismatch; + inline for (std.meta.fields(BuildIdentity)) |field| { + const before = @field(report.build, field.name); + const after = @field(build_identity, field.name); + const same = if (field.type == []const u8) std.mem.eql(u8, before, after) else before == after; + if (!same) return error.BaselineBuildMismatch; + } + if (report.cols != opts.cols or report.rows != opts.rows or report.reps != opts.reps or + ((report.only == null) != (opts.only == null))) return error.BaselineMismatch; + if (opts.only) |name| if (!std.mem.eql(u8, name, report.only.?)) return error.BaselineMismatch; + if (report.fixtures.len != fixtures.len) return error.BaselineCoverage; + + var shapes: [fixtures.len]bool = @splat(false); + for (report.fixtures) |shape| { + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, shape.name, fixture.name)) break index; + } else return error.BaselineCoverage; + if (shapes[fi]) return error.BaselineCoverage; + shapes[fi] = true; + if (shape.lines != fixtures[fi].lines or shape.cols != fixtures[fi].cols or shape.bytes != bytes[fi]) + return error.BaselineMismatch; + } + + var base: Base = .{ .med = @splat(@splat(0)), .term_med = @splat(@splat(0)) }; + var file_seen: [std.enums.values(Op).len][fixtures.len]bool = @splat(@splat(false)); + var term_seen: [std.enums.values(TermOp).len][term_fixtures.len]bool = @splat(@splat(false)); + for (report.cells) |cell| { + if (cell.min_us > cell.med_us or cell.med_us > cell.p90_us or cell.p90_us > cell.max_us) + return error.InvalidBaseline; + const fi = for (fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else null; + if (fi) |index| { + if (opts.only) |name| if (!std.mem.eql(u8, name, cell.fixture)) return error.BaselineCoverage; + const oi = for (std.enums.values(Op), 0..) |op, op_index| { + if (std.mem.eql(u8, cell.op, op.label())) break op_index; + } else return error.BaselineCoverage; + if (file_seen[oi][index]) return error.BaselineCoverage; + file_seen[oi][index] = true; + base.med[oi][index] = cell.med_us; + } else { + if (opts.only != null) return error.BaselineCoverage; + const ti = for (term_fixtures, 0..) |fixture, index| { + if (std.mem.eql(u8, cell.fixture, fixture.name)) break index; + } else return error.BaselineCoverage; + const oi = for (std.enums.values(TermOp), 0..) |op, index| { + if (std.mem.eql(u8, cell.op, op.label())) break index; + } else return error.BaselineCoverage; + if (term_seen[oi][ti]) return error.BaselineCoverage; + term_seen[oi][ti] = true; + base.term_med[oi][ti] = cell.med_us; } - // op labels repeat across the two tables ("render", "key-down"); the - // fixture names never do, so the pair still names exactly one cell - for (std.enums.values(TermOp), 0..) |op, oi| { - if (!std.mem.eql(u8, op.label(), c.op)) continue; - for (term_fixtures, 0..) |fx, fi| { - if (std.mem.eql(u8, fx.name, c.fixture)) b.term_med[oi][fi] = c.med_us; - } + } + for (fixtures, 0..) |fixture, fi| { + if (opts.only) |name| if (!std.mem.eql(u8, name, fixture.name)) continue; + for (file_seen) |row| if (!row[fi]) return error.BaselineCoverage; + } + if (opts.only == null) for (term_seen) |row| { + for (row) |seen| if (!seen) return error.BaselineCoverage; + }; + return base; +} + +fn selfTest() !void { + const testing = std.testing; + for ([_][]const []const u8{ + &.{ "--reps", "bad" }, &.{ "--reps", "-1" }, &.{ "--cols", "65536" }, &.{ "--rows", "" }, &.{ "--terminal-mib", "bad" }, + }) |args| try testing.expectError(error.InvalidNumber, Options.parse(args)); + for ([_][]const []const u8{ + &.{ "--reps", "0" }, &.{ "--cols", "0" }, &.{ "--rows", "0" }, + &.{ "--cols", "7" }, &.{ "--rows", "4" }, &.{ "--terminal-mib", "0" }, + &.{ "--terminal-mib", "257" }, &.{ "--terminal-mib", "1", "--cols", "31" }, &.{ "--terminal-mib", "1", "--rows", "257" }, + }) |args| try testing.expectError(error.InvalidMeasurement, Options.parse(args)); + for ([_][]const []const u8{ + &.{"--reps"}, &.{"--cols"}, &.{"--rows"}, &.{"--only"}, &.{"--base"}, &.{"--terminal-mib"}, + }) |args| try testing.expectError(error.MissingValue, Options.parse(args)); + try testing.expectError(error.UnknownOption, Options.parse(&.{"--typo"})); + try testing.expectError(error.UnknownFixture, Options.parse(&.{ "--only", "typo" })); + for ([_][]const []const u8{ + &.{ "--json", "--base", "old.json" }, &.{ "--creation", "--only", "small" }, + &.{ "--creation", "--base", "old.json" }, &.{ "--self-test", "--json" }, + &.{ "--terminal-mib", "1", "--only", "small" }, &.{ "--terminal-mib", "1", "--creation" }, + &.{ "--terminal-mib", "1", "--base", "old.json" }, &.{ "--mini", "--creation" }, + &.{ "--mini", "--terminal-mib", "1" }, &.{ "--mini", "--base", "old.json" }, + &.{ "--mini", "--only", "small" }, + }) |args| try testing.expectError(error.IncompatibleOptions, Options.parse(args)); + const selected = try Options.parse(&.{ "--only", "small", "--reps", "2", "--cols", "80", "--rows", "24" }); + try testing.expectEqual(@as(usize, 2), selected.reps); + try testing.expectEqual(@as(u16, 80), selected.cols); + try testing.expectEqual(@as(u16, 24), selected.rows); + try testing.expectEqualStrings("small", selected.only.?); + _ = try Options.parse(&.{ "--cols", "8", "--rows", "5" }); + _ = try Options.parse(&.{ "--creation", "--cols", "1", "--rows", "1" }); + const terminal = try Options.parse(&.{ "--terminal-mib", "64", "--json", "--reps", "3" }); + try testing.expectEqual(@as(usize, 64), terminal.terminal_mib.?); + const mini = try Options.parse(&.{ "--mini", "--json", "--reps", "1" }); + try testing.expect(mini.mini and mini.json and mini.reps == 1); + for ([_]u16{ 32, 120, 512 }) |cols| { + var buffer: [16 * 1024]u8 = undefined; + const bytes = try terminalBatch(&buffer, 37, cols); + try testing.expect(bytes.len >= 8192 and bytes.len <= buffer.len); + try testing.expect(std.unicode.utf8ValidateSlice(bytes)); + try testing.expect(std.mem.indexOf(u8, bytes, "λ界") != null); + try testing.expect(std.mem.endsWith(u8, bytes, "\x1b[32mTURN 0000000037 READY\x1b[0m")); + try testing.expectError(error.WriteFailed, terminalBatch(buffer[0..16], 37, cols)); + } + + var samples = [_]u64{ 2, 1 }; + const stats = summarize(&samples); + try testing.expectEqual(@as(u64, 2), stats.p90_us); + try testing.expect(stats.med_us <= stats.p90_us); + const cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const terms: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{ .min_us = 1, .med_us = 2, .p90_us = 3, .max_us = 4 })); + const bytes: [fixtures.len]usize = @splat(1024); + for ([_]Options{ .{}, selected }) |opts| { + var storage: [json_report_max_bytes]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + try writeJson(&out, &cells, &terms, &bytes, opts); + const valid = out.buffered(); + const base = try parseBase(valid, opts, &bytes); + try testing.expectEqual(@as(u64, 2), base.med[0][0]); + var different = opts; + different.cols += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.reps += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + different = opts; + different.only = if (opts.only == null) "small" else null; + try testing.expectError(error.BaselineMismatch, parseBase(valid, different, &bytes)); + var changed_bytes = bytes; + changed_bytes[0] += 1; + try testing.expectError(error.BaselineMismatch, parseBase(valid, opts, &changed_bytes)); + try testing.expectError(error.InvalidBaseline, parseBase(valid[0 .. valid.len - 3], opts, &bytes)); + + const parsed = try std.json.parseFromSlice(Report, gpa, valid, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + var report = parsed.value; + report.cells = report.cells[1..]; + var short: std.Io.Writer = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + report = parsed.value; + const saved = report.cells[1]; + @constCast(report.cells)[1] = report.cells[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[1] = saved; + const first = report.cells[0]; + @constCast(report.cells)[0].op = "unknown"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + @constCast(report.cells)[0].p90_us = 0; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.cells)[0] = first; + const shape = report.fixtures[1]; + @constCast(report.fixtures)[1] = report.fixtures[0]; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineCoverage, parseBase(short.buffered(), opts, &bytes)); + @constCast(report.fixtures)[1] = shape; + report.harness = "another harness"; + short = .fixed(&storage); + try std.json.Stringify.value(report, .{}, &short); + try testing.expectError(error.BaselineHarnessMismatch, parseBase(short.buffered(), opts, &bytes)); + + short = .fixed(&storage); + try std.json.Stringify.value(parsed.value, .{}, &short); + const raw = try std.json.parseFromSlice(std.json.Value, gpa, short.buffered(), .{ .allocate = .alloc_always }); + defer raw.deinit(); + var root = raw.value.object; + const identity = root.get("build").?; + try testing.expect(root.swapRemove("build")); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + for ([_]std.json.Value{ .null, .{ .string = "wrong" }, .{ .object = .empty } }) |invalid| { + try root.put(raw.arena.allocator(), "build", invalid); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + } + try root.put(raw.arena.allocator(), "build", identity); + const identity_fields = &root.getPtr("build").?.object; + for (std.meta.fieldNames(BuildIdentity)) |name| { + const value = identity_fields.get(name).?; + identity_fields.getPtr(name).?.* = switch (value) { + .bool => |v| .{ .bool = !v }, + .string => .{ .string = "different" }, + else => unreachable, + }; + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.BaselineBuildMismatch, parseBase(short.buffered(), opts, &bytes)); + try testing.expect(identity_fields.swapRemove(name)); + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + try testing.expectError(error.InvalidBaseline, parseBase(short.buffered(), opts, &bytes)); + try identity_fields.put(raw.arena.allocator(), name, value); } + short = .fixed(&storage); + try std.json.Stringify.value(std.json.Value{ .object = root }, .{}, &short); + _ = try parseBase(short.buffered(), opts, &bytes); } - return b; + if (!@import("builtin").is_test) std.debug.print("perf options, build identity, percentiles and baseline coverage: pass\n", .{}); +} + +test "perf options, percentiles and baseline coverage" { + try selfTest(); } fn readFileAlloc(path: []const u8) ![]u8 { @@ -737,8 +1552,3 @@ fn readFileAlloc(path: []const u8) ![]u8 { } return buf; } - -fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - std.debug.print("pardes-perf: " ++ fmt ++ "\n", args); - std.process.exit(1); -} diff --git a/test/snapshot.zig b/test/snapshot.zig index 5dc2e6e9..a7a1ba48 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -48,6 +48,7 @@ // resize // snap