From 464b3033ac69f6c8256c2216ac385450144740bf Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:04:26 -0300 Subject: Building gathers the platforms, options, tests, release gates and how the core, threads, detached sessions and 9P fit, from the old design notes checked against the code Co-Authored-By: Claude Opus 5.5 --- docs/design.typ | 2071 ------------------------------------------------------- 1 file changed, 2071 deletions(-) delete mode 100644 docs/design.typ (limited to 'docs/design.typ') diff --git a/docs/design.typ b/docs/design.typ deleted file mode 100644 index 80c8bb49..00000000 --- a/docs/design.typ +++ /dev/null @@ -1,2071 +0,0 @@ -// 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") -#set text(font: "New Computer Modern", size: 8.8pt) -#set par(justify: true, leading: 0.55em) -#set heading(numbering: "1.1") -#show heading: set block(above: 1.15em, below: 0.6em) -#show heading.where(level: 1): set text(size: 10.2pt) -#show heading.where(level: 2): set text(size: 9.2pt) -#show heading.where(level: 3): set text(size: 8.8pt, style: "italic") - -#show raw.where(block: true): it => block( - width: 100%, - fill: luma(246), - inset: (x: 0.5em, y: 0.45em), - radius: 1pt, - breakable: true, - text(size: 7.2pt, it), -) -#show raw.where(block: false): set text(size: 8.1pt) - -#show figure.caption: set text(size: 7.6pt) -#set figure(gap: 0.55em) -#set table(stroke: 0.4pt, inset: 0.35em) - -#let wide(caption: none, body) = place( - top, - scope: "parent", - float: true, - clearance: 1.2em, - figure(body, caption: caption), -) - -#let diagram(body) = [ - #show raw.where(block: false): set text(size: 5.9pt) - #body -] - -#place(top + center, scope: "parent", float: true, clearance: 1.5em)[ - #text(16pt)[*Pardes: A Text Environment*] - - #text(8.8pt)[Architecture and implementation of the rewrite] -] - -#place(top + center, scope: "parent", float: true, clearance: 1.4em)[ - #block(width: 82%, inset: (x: 0pt))[ - #set par(justify: true, leading: 0.52em) - #text(8.2pt)[ - *Abstract.* Pardes is a text environment in the acme tradition: columns of - panes, each pane a tag line plus a body, the body a live terminal, a file, - an image or a PDF. It is one program with five thin shells — a terminal - over libvaxis, an SDL3 window, a browser tab driven by vanilla JavaScript, - an AppKit application, and an ESP32-P4 microcontroller — where the - 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 - 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. - ] - ] -] - -= What it is - -== The acme inheritance - -Columns of panes; each pane is a tag line plus a body; the body is a live -terminal, a file, an image, or a PDF. The mouse carries meaning — left selects, -middle executes, right looks. Everything on screen is text and all text is -equally alive, whether the shell printed it or the user typed it. - -Two acme mechanisms are reproduced rather than reinterpreted. A *dump* is the -session as data: `pardes -l state.zon` on every platform reconstructs the panes -— terminals by replaying their raw VT streams into fresh emulators, files, -images and PDFs from their bytes. Editable tag tails are stored separately from -their dynamic live prefixes, and image records retain PETSCII, palette, and -ASCII renderer choices. A PDF rides the dump's `image` kind, carrying its path, -its bytes and the page it was on. The reader validates weights, scroll ranges, -pane references, and bounded tag tails before constructing anything; invalid -base64 fails instead of silently becoming empty content. A PDF record whose path -cannot be opened — or a build without MuPDF — falls back to an ordinary file -pane with those exact embedded bytes and its original editable tail. - -That is what collapses the prototype's *two* applications into one. Its browser -build was a separate 800-line read-only replay viewer; here loading another -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/fs.zig` -(@fs). - -== One core, five shells - -Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and -events, and you read state out of it. Every platform owns its own event loop and -its own renderer; the core owns everything the user would recognize as pardes. - -*The core* owns layout, panes, modes, selection, click semantics, themes, and the -text of the UI. Pure state machine: `update(event)` mutates, `render(arena)` -returns the surface. No rendering, no event loop, and no IO that has an effect -for it — but "no syscalls" was never true and is less true now: `look` reads a -file a Look opened, walks a directory for Find and reads every candidate for -Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those are the -ones that are cheaper done in place than round-tripped through an effect and -back; everything with a lifetime — a pty, a window, the clipboard — is still -asked for. - -*A shell* is one MODULE per platform: tty is one file, gui adds the CRT and -gamepad files plus a C font loader and eight GLSL shaders, and web, macOS and the -board each add a host language or a host repository. It owns the event loop, -translates native input into core events, renders the core's surface, and -performs the core's requested effects — spawn a shell, write a pty, open a link. - -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 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 `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 -launches therefore cannot truncate, source, or replace one another's startup -files. - -== A shell need not share the process - -`pardes --detach[=name]` runs the core with no terminal of its own and -`pardes --attach[=name]` makes a frontend of it over a unix socket, so one -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. 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` -reaches a resource, and every method a host leaves null is answered by -`host_io.Fallback` inside the same process.])[ - #diagram[ - #cetz.canvas(length: 0.995cm, { - import cetz.draw: * - set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35)) - - // ---- the core ---- - rect((6.0, 0.9), (11.6, 4.3), name: "core") - content((8.8, 4.02), text(7.6pt)[*the core* --- `src/pardes.zig`]) - line((6.0, 3.78), (11.6, 3.78)) - content((8.8, 3.42), text(6.2pt)[`update(ev)` --- one dispatch, mutates]) - content((8.8, 2.92), text(6.2pt)[`render(arena)` #sym.arrow.r `*Surface`]) - content((8.8, 2.42), text(6.2pt)[`emit(e)` #sym.arrow.r fixed ring, `limits.effect_cap`]) - content((8.8, 1.92), text(6.2pt)[`nextEffect()` #sym.arrow.r `perform(e)`]) - content((8.8, 1.32), text(6.2pt)[single-threaded by construction]) - - // ---- native input ---- - rect((0, 3.0), (4.3, 4.3), name: "in") - content((2.15, 3.98), text(7.0pt)[*native input*]) - content((2.15, 3.52), text(6.0pt)[termios+vaxis / SDL / DOM /]) - content((2.15, 3.19), text(6.0pt)[AppKit / a byte sink on a UART]) - line("in.east", (6.0, 3.65), mark: (end: "stealth")) - content((5.15, 3.92), text(6.4pt)[`Event`]) - content((5.15, 3.5), text(5.6pt)[16 arms]) - - // ---- surface out ---- - rect((13.3, 3.0), (18.0, 4.3), name: "paint") - content((15.65, 3.98), text(7.0pt)[*paint the grid*]) - content((15.65, 3.52), text(6.0pt)[vaxis cell-by-cell / glyph atlas /]) - content((15.65, 3.19), text(6.0pt)[DOM cells / CoreText / ANSI]) - line((11.6, 3.65), "paint.west", mark: (end: "stealth")) - content((12.5, 3.92), text(6.4pt)[`Surface`]) - content((12.5, 3.5), text(5.6pt)[cells, cursor,]) - content((12.5, 3.16), text(5.6pt)[images, tracks]) - - // ---- effect out ---- - rect((13.3, 1.1), (18.0, 2.5), name: "perf") - content((15.65, 2.2), text(7.0pt)[*perform*]) - content((15.65, 1.78), text(6.0pt)[fork a pty, write a path, mark a]) - content((15.65, 1.45), text(6.0pt)[directory, put text on a clipboard]) - line((11.6, 1.8), "perf.west", mark: (end: "stealth")) - content((12.45, 2.05), text(6.4pt)[`Effect`]) - content((12.45, 1.62), text(5.6pt)[18 arms]) - - // ---- the vtable ---- - rect((6.0, -1.0), (11.6, 0.4), name: "vt") - 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_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]) - content((2.15, -0.84), text(6.0pt)[clipboard, silent ptys]) - line("vt.west", "fb.east", mark: (end: "stealth")) - - // ---- answers loop back. Routed through the one corridor that is free of - // every box (x between `fb`/`in` at 4.3 and the core column at 6.0) and - // below the lowest box edge, so the canvas stays inside 0..18 and cannot - // bleed into the page margins. - line((15.65, 1.1), (15.65, -1.6), (5.72, -1.6), (5.72, 3.58), - mark: (end: "stealth"), stroke: (dash: "dashed")) - content((9.0, -1.94), text(5.8pt)[an answer returns as an ordinary `Event`: `paste`, `lsp_resp`, `pipe_resp`, `file_changed`]) - }) - ] -] - -The boundary is three plain data types and one struct of function pointers. - -== `Event`: what comes in - -Sixteen arms (`pardes.Event`), in declaration order: -`key`, `mouse`, `resize`, `output`, `eof`, `lsp_resp`, `pipe_resp`, -`file_changed`, `paste`, `command`, `pdf_scroll`, `pinch`, `touch_scroll`, -`pointer_leave`, `tick`, `fs_req`. - -`key` carries typed `text`. `mouse` carries press/release/motion/drag; button -left, middle, right and the four wheel directions; a cell position; and the -`ctrl` flag, because Ctrl-left-click is goto-definition. `resize` carries cols, -rows, and — in a MuPDF build — the pixel size of one cell. `output` is bytes a -pty produced, tagged with the pane id. `command` is one builtin line arriving -from another process over the nested socket. `pointer_leave` exists because an -out-of-range motion must clamp onto the last grid cell and a pointer that has -left the drawable area must not: otherwise leaving the window previews whatever -is under the final cell. `fs_req` is one filesystem request from a process that -opened a file under the control mount, and its answer leaves as an -`Effect.fs_reply` in the same update. - -Four of the arms are answers to something the core asked for; @effect -names the asks. - -Touch policy belongs to the shell: web maps a one-finger tap to right-button -LOOK and a drag past its tap slop to natural scrolling; native SDL keeps its -two-finger gestures. A joystick cursor is a motion plus buttons. The shells do -this translation and nothing else with input: one event vocabulary, five -dialects translated at the door. - -`postEvent` is a value queue of 64 and asserts what cannot survive the trip -(`Pardes.postEvent`): `output`, `paste`, `lsp_resp`, `pipe_resp`, -`file_changed`, `command` and `fs_req` all carry a borrowed slice, so they are -`unreachable` there and must be handed to `update` directly inside the host's -borrow window. That is also what keeps the pty read path copy-free. - -== `Surface`: what goes out - -`cols`, `rows`, a grid of cells, a cursor, pixel attachments, and a bounded list -of plain panel-transition tracks (`pardes.Surface`). This is the -*canonical interface*: it is literally what the tty shell hands to vaxis, cell -by cell. SDL rasterizes the same grid through a glyph atlas; the browser reads a -packed copy and patches native DOM cells. If it cannot be expressed in the -surface, it does not exist in pardes. - -Pixel images ride along as a list of PLACEMENTS rather than one per pane — a PDF -pane contributes every page its viewport intersects, and pages can be -arbitrarily short. The SDL shells blit RGBA; the tty shell uses kitty graphics -when available and falls back to the petscii matcher. Transition tracks identify -their pane by slot and serial and carry only phase, effect, frame, and from/to -cell boxes. Canonical layout is committed immediately; tracks are finite -presentation data. - -== `Effect`: what is asked for - -Eighteen arms (`pardes.Effect`). The core never performs IO for any of -them; it asks. - -```zig -pub const Effect = union(enum) { - spawn: struct { pane: u8, cwd: Buf(256) }, - write: struct { pane: u8, bytes: Buf(64) }, - resize_pty: struct { - pane: u8, cols: u16, rows: u16 }, - open_link: Buf(256), - save_file: struct { pane: u8 }, - save_text: struct { - pane: u8, serial: u32, path: Buf(256) }, - write_dump, - set_clipboard, - read_clipboard, - lsp: struct { id: u32, kind: lsp.Kind, - pane: u8, offset: u32, arg: Buf(128) }, - pipe: struct { id: u32 }, - watch: struct { pane: u8, on: bool }, - theme_file: struct { - generation: u32, on: bool }, - dump_themes: struct { pane: u8 }, - fs_reply: filesystem.Reply, - attach: struct { - pane: u8, name: Buf(attach_name_max) }, - detach: struct { pane: u8 }, - quit, -}; -// doc comments elided; see the file -``` - -Every arm is a fixed-size value, and that is the constraint the widths record. -Unbounded content is never in the union: `save_file` names a pane and the shell -reads the bytes off the core, and `save_text` carries the *path* — bounded -exactly like a spawn's cwd, so two saves armed in one batch cannot cross — while -the bytes are read at drain time. `save_text` also carries the pane's `serial`, -so a slot freed and reused before the drain writes nothing rather than another -pane's text to that path. `attach_name_max` is 256 for the same reason `spawn`'s -cwd is, and reusing that width is why the arm costs the ring nothing: -`save_text` is still the widest member (`pardes.attach_name_max`). - -Four asks have an answer coming back. `read_clipboard` returns an ordinary -`paste` event — or nothing at all when the shell cannot read the clipboard, which -is most terminals, since they refuse the OSC 52 read. `lsp` returns `lsp_resp`. -`pipe` returns `pipe_resp`. `watch` returns `file_changed` whenever the shell -notices a text file or PDF moved under it. - -`theme_file` carries only a generation because the path lives in the core's fixed -request buffer, which keeps an already-large ring compact. `fs_reply` carries no -bytes either: `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. - -`attach` and `detach` are the two arms no host method performs directly. `attach` -must not tear anything down (@swap); `detach` is served only by a -detached core's host, and the null case is the point rather than an oversight — -a local tty or SDL shell has no session to leave, so `perform` reports that on -the pane's message row instead of quietly quitting something (the `.detach` arm of -`Pardes.perform`). - -The watch path, in full, because it is the ask with the most machinery behind -it. The tty and SDL hosts and the detached daemon implement it in -`src/file_watch.zig`, with Linux inotify or a macOS kqueue behind one -mark/reconcile transaction; the native macOS host watches with debounced -DispatchSources instead, then restats the exact path under a pane-generation -guard. All three macOS watchers mark the same two things for the same reason: -the file catches in-place writes while the parent directory follows rename-over -saves, and the file mark is re-armed once such a save has moved the inode. -Text snapshots are filtered by their content hash; -PDFs use bounded inode/size/time identity and commit it only when equal stats -bracket a successful transactional MuPDF reopen (`file_watch.Generation`). A -mismatched transaction gets one bounded self-retry. This catches rename-over -saves without reading a large PDF merely to notice it changed, or spinning on a -malformed one. A PDF response retains its reading position and pane settings. -Web has no filesystem watcher, and nothing in the core waits for one. - -== The effect ring - -```zig -pub fn emit(p: *Pardes, e: Effect) void { - if (p.effects_len == p.effects.len) return; - const tail = (p.effects_head + - p.effects_len) % p.effects.len; - p.effects[tail] = e; - p.effects_len += 1; -} - -pub fn nextEffect(p: *Pardes) ?Effect { - if (p.effects_len == 0) { - p.effects_head = 0; - return null; - } - const e = p.effects[p.effects_head]; - p.effects_head = - (p.effects_head + 1) % p.effects.len; - p.effects_len -= 1; - return e; -} -``` - -`emit` REFUSES when full and never evicts (`Pardes.emit`). Byte -order is the reason: a `.write` dropped from the middle of a run would reorder a -pty's input, and a `.write` dropped from the tail merely truncates it. - -Capacity is 4096 on every hosted platform and 128 on the board -(`limits.effect_cap`). No capacity can deadlock the drain, because `pump` empties -the ring on every iteration with an unconditional `while (nextEffect())` — -including effects `perform` itself queues — so the only question a capacity -answers is how large a single-pump *burst* may be. The one producer that can -burst is `emitWrite`, which chunks arbitrary bytes into 64-byte `.write` effects -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_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. - wait_input: ?*const fn ( - ctx: ?*anyopaque, - timeout_ms: u32, - ) void = null, - present: ?*const fn ( - ctx: ?*anyopaque, - surface: *const pardes.Surface, - ) void = null, - // ... - tty_taken: ?*const fn ( - ctx: ?*anyopaque, - pane: u8, - ) bool = null, - // ... -}; -``` - -A null method is not an error: the core substitutes a default backed by ordinary -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 cores keep independent state. - -The fallback filesystem is not empty. It is pardes's own source, embedded -(`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. - -What is deliberately NOT in the vtable: whether a capability EXISTS in this -build. That stays comptime and stays next to the code it shapes -(`pardes.platform`, `pardes.hosted`, `pardes.pdf_enabled`, -`pardes.can_attach`, `pardes.terminal_panes`, `builtins.capabilities`, -`PdfSlot`). A vtable cannot make a field zero-sized or a builtin absent from an -enum. Comptime decides what a build HAS; the vtable decides who SERVES it at -runtime. - -`pardes.can_attach` is the sharpest of those, because it exists to close a gap -the coarser gate left open. `Attach` and `Detach` were gated on `pardes.hosted`, -and macOS is hosted: it has a unix socket and it compiles `detached/`. What it -does not do is POLL. `takeAttach` has to be a poll rather than a host method -precisely because attaching replaces the core the call is running inside -(@swap), and `src/macos.zig` never calls it — so there the word -parsed, queued an effect, stored a request in `attach_buf`, and then did nothing -at all, for ever, silently. `can_attach` admits exactly the tty and gui -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. - -=== Callback ownership - -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 - -```zig -pub fn pump(p: *Pardes, h: Host) !void { - p.host = h; - const v = h.vtable; - if (v.wait_input) |f| - f(h.ctx, if (p.animationActive()) - 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.poll_frame) |f| f(h.ctx); - _ = p.frame_arena.reset(.retain_capacity); - const surface = try p.render( - p.frame_arena.allocator()); - if (v.present) |f| f(h.ctx, surface); - if (v.post_present) |f| f(h.ctx); - // ... -} -``` - -`Pardes.pump`, eighteen lines in the source, and the order of them is the -architecture. Input first, in whichever host owns the sleep; -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. `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`. - -`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. - -== Platform divergence is comptime - -There is one deliberately platform-divergent file by design: `look.zig` holds -path and `:line` resolution, URL detection, and the per-platform outcomes. The -divergence is a comptime switch on `pardes.platform`, used the way the stdlib -switches on `os.tag`, so every platform's behaviour sits in the same screenful. - -```zig -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, - }; -``` - -`look.platform_has_fs`. An isolated build has no filesystem *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 core's one `pointerOperand` primitive (in `exec.zig`) owns click-word expansion and is shared -verbatim by right-click and the delayed hover preview; that policy stays beside -input because it also observes live pane selections and wrapped grid coordinates. - -Each pane kind keeps its storage and operations together in its own file -(`File.zig`, `terminal.zig`, `Output.zig`, `mini.zig`, `pdf_view.zig`, and the -image pane in `image.zig`), reached through `panes.zig` beside `Pane`. -`layout.zig` owns placement and presentation state. `pardes.zig` handles input -and cross-pane state directly, without a pane vtable; the rest of the editor -is split by thing as acme is: `Text.zig`, `edit.zig`, `normal.zig`, `tagline.zig`, -`look.zig`, `exec.zig`, `mouse.zig`, `Messages.zig`, `Pipe.zig`, `colors.zig`, -`surface.zig`, `body_layer.zig`, `Layer.zig` and `dump.zig`. Integration fixtures live -in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`. - -= State - -== One struct, fixed where it can be - -The whole state is one struct. Panes themselves are heap-allocated on demand and -their contents (file bytes, the yank register, PDF rasters, tree-sitter state) -grow with what you open; everything else is sized at init. - -```zig -Pardes - 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_io.Fallback - fs: fs.Namespace - -Pane - 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 -is also why the jump stack's depth is what it is: the depth that matters is -"more visits than you can hold in your head", not vim's hundred. - -== Cells - -```zig -pub const CellStyle = struct { - fg: Color = .default, - bg: Color = .default, - bold: bool = false, - dim: bool = false, - italic: bool = false, - blink: bool = false, - reverse: bool = false, - invisible: bool = false, - strikethrough: bool = false, - ul: enum { off, single, double, - curly, dotted, dashed } = .off, - 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). -pub const Cell = struct { - text: [7]u8 = @splat(' '), - len: u8 = 1, - style: CellStyle = .{}, - default: bool = true, - - pub fn grapheme(c: *const Cell) []const u8 { - return c.text[0..c.len]; - } - // ... -}; -``` - -`pardes.CellStyle` and `pardes.Cell`. Seven bytes of `text` because a cell holds a -complete grapheme cluster and not a codepoint: keeping the cluster is what makes -combining marks visible and keeps ZWJ, modifier, flag and Indic sequences in the -same screen cell that cursor and edit maths treat as one unit. `len` bounds it, -and `default` distinguishes "a space was painted here" from "nothing was". - -That distinction is load-bearing twice over. `visuallyEqual` compares only what -a shell can present — bytes past `len` are scratch left by earlier graphemes and -must never manufacture a diff, and an unpainted default cell has no visible -style or text either. And `printableAscii` returns `' '` for a default cell but -`null` for a painted multi-byte one, which is what admits a cell to the ASCII -transition walk (@diff). - -== Columns and panes are arithmetic - -Layout is arithmetic, not objects: columns are weights over the width, panes are -weights over the column. - -#figure( - placement: auto, - caption: [The layout model. Each pane is a narrow gutter strip (move box and - scrollbar) plus a tag row plus a body. `col_weight` is 64-bit fixed point with - 32 fractional bits, so every possible column split divides an initial weight - exactly; `vweight` is an `f32` over its own column. `col_terms[c][k]` is the - pane in slot position `k` of column `c`, and `rects[id]` is where that pane - landed this frame.], - diagram[ - #cetz.canvas(length: 1cm, { - import cetz.draw: * - set-style(stroke: 0.35pt, mark: (fill: black, scale: 0.3)) - let w = 7.6 - let h = 4.5 - - rect((0, 0), (w, h)) - line((0, h - 0.28), (w, h - 0.28)) - content((w / 2, h - 0.14), text(4.9pt, style: "italic")[topbar, execute-only]) - - line((2.8, 0), (2.8, h - 0.28)) - line((5.2, 0), (5.2, h - 0.28)) - line((2.8, 1.8), (5.2, 1.8)) - - let pane(x0, y0, x1, y1, kind) = { - line((x0 + 0.26, y0), (x0 + 0.26, y1 - 0.26)) - line((x0, y1 - 0.26), (x1, y1 - 0.26)) - content(((x0 + x1) / 2, y1 - 0.13), text(4.9pt, style: "italic")[tag: #kind]) - } - pane(0, 0, 2.8, h - 0.28, [file]) - pane(2.8, 1.8, 5.2, h - 0.28, [terminal]) - pane(2.8, 0, 5.2, 1.8, [output]) - pane(5.2, 0, w, h - 0.28, [PDF]) - content((1.45, 2.3), text(4.9pt, style: "italic")[body]) - content((6.4, 2.0), text(4.9pt, style: "italic")[body]) - - line((0.04, -0.3), (2.76, -0.3), mark: (start: "stealth", end: "stealth")) - content((1.4, -0.58), text(5.4pt)[`col_weight[0]`]) - line((2.84, -0.3), (5.16, -0.3), mark: (start: "stealth", end: "stealth")) - content((4.0, -0.58), text(5.4pt)[`col_weight[1]`]) - line((5.24, -0.3), (w - 0.04, -0.3), mark: (start: "stealth", end: "stealth")) - content((6.4, -0.58), text(5.4pt)[`col_weight[2]`]) - - line((5.0, 1.86), (5.0, h - 0.34), stroke: (dash: "dotted"), - mark: (start: "stealth", end: "stealth")) - content((4.2, 3.0), text(5.2pt)[`vweight`]) - line((5.0, 0.06), (5.0, 1.74), stroke: (dash: "dotted"), - mark: (start: "stealth", end: "stealth")) - content((4.2, 0.9), text(5.2pt)[`vweight`]) - }) - ], -) - -Horizontal layout weights are fixed-point integers and geometry rounds -cumulative boundaries. Splitting a column replaces only its weight $W$ by -$A + B = W$ at the same position. Therefore every boundary outside the source -column is bit-identical before and after the split, including at awkward -non-dyadic screen widths; only the source and new column can receive movement -tracks. Vertical splits apply the corresponding rule to the source pane's -weight. - -`splitBelow` shrinks only the source pane; `absorbVWeight` gives a dying pane's -weight to one sibling; an emptied column hands its width to a neighbour. -Minimal motion is the invariant: an operation on one pane may not move panes it -does not touch. - -== Runtime settings are one table - -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 -(`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. - -Requested and effective are separate fields on purpose. Resolving a shell name -to an executable, or a font name to a face, belongs to the native host; the core -retains what the host actually chose and marks a changed request `pending` until -the next spawn or the next atlas acknowledges it. A rejected request therefore -stays queryable without claiming it is on screen. - -= Modes and selections - -== Three modes and a boolean - -`Pane.mode` is `enum { normal, insert, tty }` (`pardes.Mode`). - -*normal* is the helix motion model. *insert* is click-and-type: terminals get -splice runs (shift right, never overwrite, anchored to an absolute row), files -get real edits. *tty* is raw pty forwarding — a Ctrl-key toggle, default Ctrl-b, -`--tty-toggle` — where the mouse is still usable, entry does a -`promptClickMove`, and prompts become visible again (they are hidden in the -other two modes via OSC 133). - -Select is not a fourth mode. `v` sets `Pane.select`, a bool on top of normal, -which makes motions extend from a fixed anchor instead of replacing the range; -`pane.mode` stays `.normal`, and insert/tty transitions drop it -(`Pane.select`). It is *displayed* as a fourth mode name, "select", -because that is what the user is in — but nothing in the dispatch branches on a -fourth mode. - -Since the helix motion model landed, a traversal motion SELECTS the range it -crossed. That is why there is no verb+noun grammar and why `i` after `w` types -at the selection's start. - -== One primary range and up to 63 more - -`MAX_SELS` is 64 (`pardes.MAX_SELS`). 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` plus `vsel` — and the other ranges in -`sels: [MAX_SELS - 1]SelRange`, document-ordered and disjoint -(`Pane.sels`, `Pane.nsel`). - -That split is the whole design. Every motion, operator, renderer and mouse path -still reads one selection, so with `nsel == 0` not a byte of behaviour moves, and -the differential and snapshot suites keep proving it. The extra ranges are driven -by replaying the single-selection key handler once per range (`replaySels`). - -`s`/`S` — select and split by regex — snapshot the selection they were armed on -in `sel_snap` and re-derive the preview FROM that snapshot on every keystroke, -rather than from the previous preview. This is what helix's `regex_prompt` does, -it is what makes typing a pattern one character at a time land on the same answer -as pasting it whole, and it is what makes Esc a plain restore with nothing else -to undo. The snapshot also records whether the range was a *user-intent* -selection, so restoring cannot silently promote motion residue into something -the acme chords will act on (`Pane.sel_snap`). - -== Three buttons, three meanings - -The mouse selections are `[3]Sel`, one per button, block-shaped, in text-area -coordinates where `r` counts from the tag row (`pardes.Sel`). Each -has three states, `none`, `dragging` and `done`, which is what lets a kept left -selection stay highlighted after the drag and still be distinguishable from one -in progress. - -Left selects and pins the cursor. Middle executes: with no drag it expands to a -file-ish word, then runs a builtin or sends the text to the shell, and it does -not focus. Right looks. A theme owns the SELECTION — `sel_bg`/`sel_fg` are one -pair per theme, and the three per-button tints and the dimmed extra cursors are -mixed off it, so what stays fixed is the distinction between buttons and not the -colours. - -`Drag` is a `union(enum)` with six arms (`mouse.Drag`): `none`, -`border_v`, `border_h`, `move`, `column_move`, `select`. `border_v` carries an optional -`corner`: when the press lands on a cell that is both a column's vertical border -and one of the two adjoining columns' own horizontal borders, the one drag moves -*both* boundaries — never three, and when both columns happen to be split at the -grabbed row the left one wins, so the gesture that existed before is bit-for-bit -unchanged. - -== The tag is a text - -A tag is a `Text` (`Text.zig`), as a body is: acme's `Text`, one per -`what` — the body, the pane's tag, the prompt line, a column's tag and the -workspace tag. Each holds its own cursor, selections, mode and undo, so tags -are edited by the same `edit.zig` and `normal.zig` code as bodies, and the one -key they do not share is `:`, which moves between a pane's body and its tag. -A tag holds any number of lines; a pane's tag takes a row per line up to -`MAX_TAG_ROWS`, each drawn as a tag layer of its own. - -A pane's tag shows a live prefix (path, dirty marker, PDF page) before the text -it owns, and that prefix is computed at every read, never stored -(`tagline.tagPrefix`). Everything sees the whole line as shown, prefix ++ -text: render, the mouse, Look, Exec, the 9P `tag` file and the keyboard, -whose motions reach the path as acme's do. The prefix is read-only: an edit -installs only what follows it, and one that would change it is refused -(`edit.setEditText`, `Text.refused`). Because the prefix is computed, sync -moves the tag's positions with it when its length changes (`Pane.tag_lead`). -Editing the path is a separate draft (`Pane.prompt = .name`) committed by -Enter, since a buffer's name is not text it owns; a click or a key typed -into it starts one. - -The tag's text is bounded by `limits.max_tag_tail`: the 9P `tag` file and a -dump reader refuse input that does not fit rather than truncating it. - -= Diffing and presenting a frame - -== Eleven transitions - -`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` -(`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. - -#table( - columns: (auto, auto, auto, 1fr), - align: (left, left, left, left), - table.header([*id*], [*name*], [*frames*], [*composed by*]), - [1], [`slide`], [12], [backend shader / tty grid], - [2], [`zoom`], [14], [backend shader / tty grid], - [3], [`dissolve`], [10], [backend shader / tty grid], - [4], [`ascii`], [13], [core], - [5], [`vertical`], [12], [backend, lifecycle only], - [6], [`edges`], [12], [core], - [7], [`fall`], [14], [core], - [8], [`wave`], [14], [core], - [9], [`curtain`], [12], [core], - [10], [`scramble`], [12], [core], - [11], [`typewriter`], [14], [core], -) - -The split in the last column is the design. `composedByCore` is true for every -*character* effect: the core writes the finished glyphs into the published -`Surface`, so no backend owns a byte walk, a stagger, or a noise threshold, and -every renderer presents the same byte at a given frame. The geometry effects -(`slide`, `zoom`, `vertical`) and `dissolve` are the ones a backend evaluates, -because they are transforms over rectangles rather than choices about characters. - -Easing follows from that too. A sweep and a typewriter are constant-rate by -definition, so `curtain` and `typewriter` are `linear`: easing their head would -make the pass visibly hesitate mid-pane. Character walks and per-cell locks read -best with a slow start, a fast middle and a slow settle, so `ascii`, `fall` and -`scramble` are `smoother`. - -The core detects opening and moving rectangles when it commits layout and -publishes only active tracks. A separate dense closing-track list is -presentation-only state for a pane whose functional lifetime has already ended, -which is why it needs no pane owner and no serial guard. Pointer input inverts -the presented slide/zoom/vertical rectangle back to the canonical grid, lets -unchanged dissolve and ASCII cells through immediately, and rejects closing -pixels, so pixels and gestures cannot disagree during a transition. - -== The semantic cell diff - -```zig -pub const PanelCellDiff = union(enum) { - unchanged, - visual, - ascii: AsciiDiff, - - pub fn between( - old: *const Cell, - new: *const Cell, - ) PanelCellDiff { - if (old.visuallyEqual(new)) return .unchanged; - if (AsciiDiff.between(old, new)) |diff| - return .{ .ascii = diff }; - return .visual; - } - - // ... -}; -``` - -`pardes.PanelCellDiff`; the elided member is `changed`, which is -`diff != .unchanged` and is what `Surface.panelCellChanged` calls. The core -retains the last successfully presented canonical grid and this typed old/new -classification, and it is the *semantics* that matter: unused grapheme bytes do -not manufacture a change, and a style-only or multi-byte change is `.visual` and -passes straight through. - -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 -(`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 -composer fills it from the longest eased byte walk in the pane's diff. - -== What each backend does with it - -TTY copies that `Surface` grid into a compositor scratch grid, clears slide and -zoom destinations, then paints moving, opening, and closing panels in order. -Cleared geometry uses the theme page colour when it is explicit and the host -terminal default only for transparent themes, so a light theme cannot flash a -dark gap. Slide and zoom change the copied rectangle; dissolve changes only diff -cells from their old value to their new value; vertical raises only an opening or -frozen closing pane inside its own clip. - -Every effect's last active sample is its exact canonical endpoint, so the -compositor returns the source surface unchanged when no track is -non-canonical — keeping the real cursor and attachments in that sample rather -than suppressing them for one frame that is otherwise pixel-identical -(`panel_compositor.compose`). Kitty placements cannot be resampled -through the character-grid transform, so a moving pane's attachment is omitted -while its geometry moves and placed again on the last active sample. - -SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect -parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII -because they have no character byte. macOS passes the same records across its -plain C ABI and composites old/new panel images in Metal and Core Image. The -scene `Crt` is one full-window pass in each native GUI; the SDL GUI's post -chain also runs Shadertoy files (`Shader`). - -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 ` 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 - -Three loops in this codebase run once per character over text that is almost -always ASCII, and each asks two or three general functions for what arithmetic -already knows. The guard that makes elision *correct* rather than merely fast is -the same in all three, and it is a statement about Unicode: 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 a printable ASCII byte followed by another -ASCII byte, or by nothing, is a complete grapheme cluster one column wide. - -```zig -// 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. -{ - const b = text[i]; - if (b >= 0x20 and b < 0x7f and - (i + 1 == text.len or text[i + 1] < 0x80)) - { - s.set(col, y, text[i .. i + 1], style); - i += 1; - col += 1; - continue; - } -} -``` - -`Surface.print`. Its own comment records the reason -it exists: this function was 26% of a keystroke when profiled in the ESP32-P4's -configuration — 40×12, no tree-sitter — which was the largest single item there. -`\t`, `\r`, the C0 controls and DEL are excluded by the range test and keep their -existing handling. - -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. - -The third is `modal.nextGrapheme` itself -(`modal.nextGrapheme`), and it is where the argument above needs its one -correction. GB3 is the single UAX #29 rule that joins two ASCII scalars: a CR -takes a following LF into the same cluster. The two range-tested loops never see -it, because `0x20..0x7e` excludes CR — but `nextGrapheme`'s fast path admits -every byte below `0x80`, so it excludes CRLF by name. It did not always, and -`graphemeStart` did: the two then disagreed about a CRLF file by exactly one -byte, a head stepped onto the offset between CR and LF, and `graphemeStart` -repaired it back onto the CR. Everything else ASCII is still O(1). - -Because `fitEnd` decides where text lands on screen, a fast path that is off by -one column *moves text*. Both of its tests therefore pin it to the general walk -it replaces rather than to a transcribed expectation: one sweeps every byte -below 0x80 against a set of neighbours at every width and start offset -(*the ASCII run in fitEnd survives an exhaustive byte sweep*), and the other runs a hand-written case list — -including `"abc\u{00e9}def"` for a hand-over mid-run, a wide glyph a width -boundary can land inside, `"a\u{0301}bc"` for a cluster the fast path must not -split, and `"e\u{0301}x"` for an ASCII byte followed by a continuation byte — -through both routes (*the ASCII run in fitEnd cuts where the grapheme walk -would*). `Surface.print` has the same -arrangement: its reference implementation is `print` with the fast-path block -deleted and nothing else changed (the test *the ASCII fast path in surface -print paints what the general arm paints*). - -= Detached sessions - -#wide(caption: [A detached session. The daemon owns the core and every -machine-local resource; a frontend owns a screen, a keyboard and a clipboard. -Counts and tag numbers from `wire.ClientTag` and `wire.ServerTag`; the routing rules -from `src/detached/server.zig:34-59`.])[ - #diagram[ - #cetz.canvas(length: 1cm, { - import cetz.draw: * - set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35)) - - // ---- the daemon ---- - rect((0, 2.0), (6.4, 5.5), name: "d") - content((3.2, 5.24), text(7.4pt)[*the daemon* --- `pardes --detach[=name]`]) - line((0, 5.02), (6.4, 5.02)) - content((3.2, 4.74), text(6.0pt)[one `Pardes`; `update` is called here]) - content((3.2, 4.38), text(6.0pt)[every pty master (`host_io.forkShell`)]) - content((3.2, 4.02), text(6.0pt)[every file (`host_io.writeFileBytes`)]) - content((3.2, 3.66), text(6.0pt)[the inotify fd (`file_watch.zig`)]) - content((3.2, 3.30), text(6.0pt)[the theme dir and the acme mount]) - content((3.2, 2.94), text(6.0pt)[16 of 21 `VTable` methods implemented]) - content((3.2, 2.58), text(6.0pt)[ONE `poll(2)` --- the only sleep]) - content((3.2, 2.26), text(5.6pt, style: "italic")[pane shells outlive every frontend]) - - // ---- the socket, drawn in segments so the labels sit in the gaps ---- - line((9.3, 2.0), (9.3, 3.02), stroke: (dash: "dashed")) - line((9.3, 3.46), (9.3, 4.72), stroke: (dash: "dashed")) - line((9.3, 5.16), (9.3, 5.6), stroke: (dash: "dashed")) - content((9.3, 5.78), text(6.2pt)[`AF_UNIX`]) - - // ---- the two directions ---- - line((12.2, 4.7), (6.4, 4.7), mark: (end: "stealth")) - content((9.3, 4.94), text(6.2pt)[`ClientTag` --- 11 tags]) - line((6.4, 3.0), (12.2, 3.0), mark: (end: "stealth")) - content((9.3, 3.24), text(6.2pt)[`ServerTag` --- 8 tags]) - content((9.3, 2.48), text(5.6pt, style: "italic")[a frame per pump, per client;]) - content((9.3, 2.18), text(5.6pt, style: "italic")[never queued, only skipped]) - - // ---- frontends ---- - rect((12.2, 4.35), (18.0, 5.5)) - content((15.1, 5.24), text(6.8pt)[*frontend* --- a terminal]) - content((15.1, 4.86), text(5.8pt)[`--attach`; vaxis paints `grid`/`cursor`]) - content((15.1, 4.52), text(5.8pt)[screen + keyboard + clipboard + a link]) - - rect((12.2, 3.1), (18.0, 4.15)) - content((15.1, 3.90), text(6.8pt)[*frontend* --- an SDL window]) - content((15.1, 3.52), text(5.8pt)[same protocol, same session, same screen]) - content((15.1, 3.22), text(5.8pt)[`screen -x`, not N sessions]) - - rect((12.2, 1.85), (18.0, 2.9)) - content((15.1, 2.68), text(6.8pt)[#sym.dots.v up to `max_clients` = 32]) - content((15.1, 2.32), text(5.8pt)[the 33rd gets a `refuse .full`, not a backlog]) - content((15.1, 2.02), text(5.6pt, style: "italic")[NO FORK HAPPENS IN A FRONTEND]) - - // ---- the message tables, full width ---- - rect((0, -2.8), (18.0, 1.5)) - content((9.0, 1.24), text(7.0pt)[*every message, and which way it crosses*]) - line((0, 1.02), (18.0, 1.02)) - let row(y, body) = content((0.3, y), body, anchor: "west") - row(0.72, text(5.6pt)[frontend #sym.arrow.r core (11): `hello` `bye` `key` `mouse` `resize` `paste` `command` `pdf_scroll` `pinch` `touch_scroll` `pointer_leave`]) - row(0.32, text(5.6pt)[core #sym.arrow.r frontend (8): `welcome` `refuse` `frame` `quit` `detach` | `set_clipboard` `read_clipboard` `open_link`]) - row(-0.16, text(5.6pt)[BROADCAST --- every screen must show the same thing: `frame`, `set_clipboard`]) - row(-0.54, text(5.6pt)[ORIGIN ELSE PRIMARY --- the answer belongs to the human who acted: `read_clipboard`, `open_link`, `detach`]) - 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: `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]) - }) - ] -] - -== The daemon owns the machine - -The `Pardes` instance lives in the detached process. A frontend owns a terminal -(or a window, or a serial panel) 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. - -The argument for putting every machine-local effect in the daemon is one -sentence: 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, 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 the daemon forks -the pane shells, writes the files, marks the directories, 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. - -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 -the process sleeps; and every queue that could grow without bound has a ceiling -with a stated answer for reaching it. Frames in particular 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. What is left in a client's -out-queue is control messages, capped by `out_backlog` and 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, and its peers are untouched. - -== The wire - -```zig -pub const ClientTag = enum(u8) { - 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, -}; - -pub const ServerTag = enum(u8) { - welcome = 0x01, - refuse = 0x02, - frame = 0x03, - quit = 0x04, - detach = 0x05, - - set_clipboard = 0x10, - read_clipboard = 0x11, - open_link = 0x12, -}; -``` - -`wire.ClientTag` and `wire.ServerTag`. Eleven tags in, eight out, and the shape of both -lists is the argument. - -Every `ClientTag` is something a human did: 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. 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. - -On the way out there are only three effects left, and which three is the whole -design: what a process nobody is looking at genuinely cannot do is put something -on THIS human's clipboard, read it back, and open a link in front of the person -who clicked it. The eight machine-local pushes — `spawn`, `pty_write`, -`pty_resize`, `write_file`, `write_dump`, `watch_file`, `watch_theme`, -`dump_themes` — were routed to ONE frontend precisely because each has one real -resource behind it, and every one of them is now performed in the daemon. Two -frontends can no longer fork two shells for pane 3 or race each other writing one -path, because neither of them writes anything. - -`detach` sits in the session range beside `quit` rather than among the three -effects, because it is not an effect the session performs on the world: it is one -frontend being told it is done. - -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 - -The frontend on the other end may be riscv32-freestanding while the core is -`x86_64` linux, so: every integer is an explicit width, little -endian, and no `usize` reaches the wire; no native struct is ever blitted, because -`@bitCast` of a Zig struct puts this compiler's field order and padding on a -socket; every union and every enum gets a tag chosen in this file and never -`@intFromEnum` of a core type, with exhaustive mapping switches, so adding a -variant to `Event` is a compile error here rather than a silent protocol -redefinition; every variable-length payload carries an explicit length prefix and -`max_payload` bounds the lot; a bool is one byte, 0 or 1, and any other value is a -decode error rather than "nonzero is true"; floats travel as their IEEE-754 -binary32 bit pattern inside an explicit `u32`. - -The codec is build-neutral for the same reason: `Event.resize.cell_pixels` exists only when -native PDF placement is compiled in, 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. - -`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: 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 -512×128×(6+20) = 1.6 MiB, and one paste is already capped at 4 MiB by the tty -frontend (`wire.max_payload`). - -== Connect first, swap second - -```zig -if (core.takeAttach()) |req| { - var attempt = detached_client.attempt( - gpa, req.name, core.screen_w, core.screen_h); - switch (attempt) { - .greeted => |client| { - attached.* = client; - break :frames; - }, - else => { - var mbuf: [256]u8 = undefined; - core.setMessage( - req.pane, - attemptEnd(&attempt, req.name) - .row(&mbuf), - ); - }, - } -} -``` - -`tty.localSession`, and the `takeAttach` block in `src/gui/gui.zig` is the same -shape. -CONNECTING IS NOT BEING ATTACHED, which is why this asks 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 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 -the function's own defers, reached by leaving its scope. - -The ordering is enforced in three places at once. The builtin only asks -(`builtins.Attach`). `Effect.attach` is unpacked into `attach_req` and -`attach_buf` by `perform`, and what the frontend acts on is what `takeAttach` -hands back AFTER the drain, not the effect value, which dies in the loop that -read it (`drainForAttach`). And `takeAttach` is consumed from the shell's -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 -(`Pardes.attach_req`). The unit test *Attach asks for a session and tears -nothing down* asserts -exactly that — after `Attach` and a full drain, `p.quit` is false and `p.panes[0]` -is still there. - -Because a failed connect is cheap, `Attach` is the one word in the session group -that is safe to press by accident, and it is the one that gets a leader chord: -`SPC s a`. `Detach` takes `SPC s D`, a capital because `sd` has been `Dump`'s -since before there was anything to detach from. Both entries sit behind -`if (pardes.can_attach)` in `src/config.zig`, and that gate is not decoration: -the leader table may only name a builtin that EXISTS, so on macOS — hosted, but -no poller — the two words are compiled out and naming them would be a compile -error. Which is the good outcome, and the reason the predicate exists. - -`Detach` is not `Attach` backwards, and the asymmetry is deliberate. Turning a -live local session into a daemon needs `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, `Detach` therefore reports rather than acts. - -== `host_io.zig`: the machine-local half - -`forkShell`, `writeFileBytes` and `writeFd`, and it is now the only copy of them: -`tty.zig`, `detached/server.zig`, `gui/gui.zig` and `macos.zig` all fork and -write through it (`src/host_io.zig`, module header). - -They did not always, and what the four copies had in common is the better -argument for the file existing than "it is shared" is: ALL FOUR were missing -`FD_CLOEXEC` on the pty master. `/dev/ptmx` is opened by `forkpty` with no -`O_CLOEXEC` and there is no flag argument to ask for one, so in every shell -pardes has ever shipped, a program in one pane could read and write another -pane's terminal. The silent half is worse and 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, so a pane delete or a respawn left an orphaned shell that never -exited — 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. - -`nested.setCloexec(master)` after the fork fixes it for all four callers at once, -and the file states the window that leaves rather than papering over it: `fcntl` -after `fork` is not atomic, so a thread that forks and execs between the 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. Closing the window in the threaded shells -means replacing `forkpty` with `posix_openpt(O_CLOEXEC)` / `grantpt` / -`unlockpt` / fork / `setsid`. - -`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 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 and makes its own copy non-blocking -in order to. - -= The board - -== An object, not a module - -`-Dplatform=esp32p4` emits ONE freestanding riscv32 object exporting the C ABI in -`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. - -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 -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 -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 -the result is a real number to hold against the board's 1.5 MiB factory -partition. - -Where the terminal is: on the host. The board writes ANSI and reads ANSI, and the -emulator at the far end of the serial line does the font rendering and answers -this program's own capability queries. That is why vaxis works here unmodified — -`Vaxis.render`, `queryTerminalSend` and `enableDetectedFeatures` all take a bare -`*std.Io.Writer`, so the transport is a parameter, while `vaxis.Tty` and -`vaxis.Loop` are termios/ioctl/SIGWINCH bound and are not used. Window size -arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like -any other input, because firmware has no `TIOCGWINSZ`. - -== The memory budget is one table - -`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 -/// shared between `.bss`, `.data` and the stack. -const board = config.platform == .esp32p4; -/// No OS means no address space to reserve -/// megabytes out of, whatever the platform is -/// called. -const reduced_target = - builtin.os.tag == .freestanding; - -pub const board_heap_bytes = 384 * KiB; -pub const effect_cap = if (board) 128 else 4096; -pub const wrap_rows = if (board) 128 else 256; -pub const cwd_buf_cap = if (board) 0 else 1024; -pub const undo_max = if (board) 16 else 256; -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; -// ... `arena` below; see the wide listing -``` - -Two booleans derive all of it, and nothing outside this file tests the platform -for a capacity again. Every cap says what it is measured against, and -`board_heap_bytes` is the number the others are measured against: the 384 KiB -chunk of L2MEM at `0x4FF40000`. It is 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 that -question, which is what the memory tests in `pardes.zig` do with it. - -Each cap is a different *kind* of shrink, which is why they are not one scale -factor. - -- `cwd_buf_cap` and `host_path_cap` go to *zero*, and zero is a type: `Text(0)` - is a zero-sized field whose `set` refuses every non-empty path, so the three - producers — the resolved shell, the picked font file, the watched theme file — - report failure instead of storing 12 KiB nothing can fill. This is the - `PdfSlot` rule applied to a capacity: the board has no filesystem, no processes - to spawn a shell for and no font picker. -- `undo_max` and `wrap_rows` shrink *gracefully*. `pushHistory` evicts and frees - the oldest once full, so the smaller ring loses the deepest undo steps and - nothing else. `wrapWidth` reads the array's own length and refuses to wrap a - pane taller than it, so a taller pane renders unwrapped rather than getting a - truncated map. -- `effect_cap` changes *behaviour*, and the file says so: `emit` has always - refused rather than evicted once full, so on the board a burst larger than 128 - effects now drops its tail where 4096 would have held it. That is reachable - only through `emitWrite`, i.e. only if a pty ever appears on this platform. -- `embedded_sources` is a capacity spelled as rodata. The allowlist is empty on - the board, because the table is about 0.95 MiB against a 1.5 MiB partition. - The API is unchanged — `all` is a zero-length array and `find` answers null — so - every caller compiles identically and simply finds nothing embedded. -- `hexdump_row_bytes` is the one that is about the *display* rather than memory. - `hexdump -C`'s sixteen needs 79 columns; the P4 drives 56 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. - -#wide(caption: [`limits.arena`. Three tiers, because the address space -differs by four orders of magnitude. The board's tier is deliberately ALL -FALLBACK: every buffer here is a `StackFallbackAllocator`'s static, which lands -in `.bss`, and on the P4 `.bss`, `.data` and the stack share one 240 KiB chunk of -L2MEM while the heap is a separate 384 KiB chunk. A megabyte-shaped reservation -would not fit, and every byte that did fit would be taken from the stack's -neighbourhood to duplicate memory the heap already has. Zero is legal and always -spills, which is exactly what an arena for a compiled-out subsystem should do.])[ -```zig -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; -}; -``` -] - -What does NOT belong in this table is capability switches. `terminal_panes`, -`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. - -That division is also why a build option selecting the board's budget on a -desktop cannot work, and the file records the attempt. `-Dmem-profile=board` was -meant to let a native test runner compile the board's capacities and boot the -core under them. 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 platform-independent and both running on the ordinary build. - -== What the board does not have - -`terminal_panes` is false there and nowhere else (`pardes.terminal_panes`), and it -is a platform gate and deliberately not one derived from the target: `web` is -freestanding too and KEEPS the emulator, because the browser shell renders a -replayed dump. False means ghostty-vt is not in the module graph at all — -`build.zig` never even asks for the dependency — which removes about 400 KiB of -flash, a `PageList` of RAM spent parsing input that cannot arrive, and a pile of -freestanding root hooks (`os.PATH_MAX`, `os.heap.page_allocator`, a cwd handle) -that the core itself does not want. - -Tree-sitter is refused outright: `-Dplatform=esp32p4` with anything but -`-Dtree-sitter=disabled` fails the build, because the grammars' parse tables are -megabytes against a 1.5 MiB partition (`build.zig:298`). MuPDF is refused for the -same reason (`build.zig:297`). - -The board gains four words nothing else has, all gated on -`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 -`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. - -`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 -(`builtins.Board.enabled`). - -= The control filesystem - -== 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_PID` (the editor's process id), `PARDES_9P` and -`PARDES_PANE` (the pane serial). The first answers whether the shell is inside -a pardes at all, the other two answer how to reach it, and they are separate -because a session whose listener never came up still owns its children. A child -launch that finds a live `PARDES_PID` and no way to reach it says so instead of -starting a second editor. Otherwise it resolves its OS-relative argument in the -child's working directory, then writes `look ` to the parent's -`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 withholds `PARDES_PID` from its direct -pane shells, which is the whole of the opt-out. Its 9P socket remains available -for control and plugins, so those shells still get `PARDES_9P` and -`PARDES_PANE`. 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 - -#wide(caption: [The build graph. One `root_mod` per invocation, rooted at -whichever file that platform's host enters through; up to three more compilations -of the same graph beside it; one `pardes_config` per distinct *frontend*, which -is the only thing two of them are allowed to disagree about. Line numbers are -`build.zig`.])[ - #diagram[ - #cetz.canvas(length: 1cm, { - import cetz.draw: * - set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35)) - let row(y, body) = content((0.3, y), body, anchor: "west") - - // ---- tier 1: inputs, full width ---- - rect((0, 6.76), (18.0, 9.5)) - content((9.0, 9.24), text(7.0pt)[*inputs, generated or read at configure time*]) - line((0, 9.02), (18.0, 9.02)) - row(8.72, text(5.8pt)[`highlights.scm` from 29 grammars #sym.arrow.r one options module]) - row(8.34, text(5.8pt)[`vendor/themes/*.toml` (214 helix) and `*.json` (11 zed) #sym.arrow.r 225 generated theme modules plus a `list.zig`, straight into the build cache]) - row(7.96, text(5.8pt)[working-tree `.zig` sources #sym.arrow.r the web shell's read-only source archive]) - row(7.58, text(5.8pt)[8 GLSL shaders #sym.arrow.r SPIR-V through `glslc` --- or `-Dprebuilt-shaders` embeds the committed `.spv` + `.glsl` pair, so `EffectCode` cannot lie]) - row(7.24, text(5.8pt)[`@import("build.zig.zon")`, UNTYPED (`:8`) #sym.arrow.r `zon.version` (`:1920`) and a `comptime` check that `zls_version` matches the pinned sha (`:36-46`)]) - row(6.90, text(5.8pt)[`gitCommit(b)` (`:1855-1867`) #sym.arrow.r `?[]const u8`, null when there is no repository, no `git`, or nothing to say]) - - // ---- tier 2: modules and the options module ---- - rect((0, 3.8), (8.7, 6.6)) - content((4.35, 6.34), text(7.0pt)[*modules that compile* `src/pardes.zig`]) - line((0, 6.12), (8.7, 6.12)) - content((4.35, 5.84), text(5.8pt)[`root_mod` (`:306`) --- its root is the file this]) - content((4.35, 5.50), text(5.8pt)[platform's host enters through:]) - content((4.35, 5.14), text(5.6pt)[`main.zig` (tty, gui) | `web.zig` | `macos.zig` | `esp32p4.zig`]) - content((4.35, 4.74), text(5.8pt)[`hx_core_mod` --- a headless second core for `hxdiff`]) - content((4.35, 4.38), text(5.8pt)[`isolated_mod` --- one comptime bool: no filesystem]) - content((4.35, 4.02), text(5.8pt)[`gui_mod` --- the same `main.zig` at `platform = .gui`]) - - rect((9.3, 3.8), (18.0, 6.6)) - content((13.65, 6.34), text(7.0pt)[`pardes_config` --- ONE PER DISTINCT FRONTEND]) - line((9.3, 6.12), (18.0, 6.12)) - content((13.65, 5.84), text(5.8pt)[`platform`, `mupdf`, tree-sitter tier, `zls_version`,]) - content((13.65, 5.50), text(5.8pt)[`esp32p4_cols`/`rows`, `theme_animation`, `zig_lib_dir`,]) - content((13.65, 5.14), text(5.8pt)[`version` (from the manifest), `commit` (from `git`)]) - content((13.65, 4.70), text(5.8pt)[`ShellConfig` (`:1874-1890`) holds every field that is a]) - content((13.65, 4.34), text(5.8pt)[property of the BUILD, so the two modules a default build]) - content((13.65, 4.00), text(5.8pt)[cannot drift apart in any field but `platform`]) - - line((4.35, 6.76), (4.35, 6.6), mark: (end: "stealth")) - line((13.65, 6.76), (13.65, 6.6), mark: (end: "stealth")) - line((9.3, 5.2), (8.7, 5.2), mark: (end: "stealth")) - - // ---- tier 3: outputs, full width ---- - rect((0, 0), (18.0, 3.3)) - content((9.0, 3.04), text(7.0pt)[*what one invocation emits*]) - line((0, 2.82), (18.0, 2.82)) - let out(y, name, what) = { - content((0.3, y), text(6.0pt)[#name], anchor: "west") - content((4.9, y), text(5.8pt)[#what], anchor: "west") - } - out(2.52, [`pardes`], [`-Dplatform=tty`, the default --- vaxis over a real terminal]) - 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`; 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]) - - line((4.35, 3.8), (4.35, 3.3), mark: (end: "stealth")) - }) - ] -] - -== One primary shell, sometimes two - -`-Dplatform` names ONE shell. Absent, the 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 a person has to -remember are two (`also_gui` in `build.zig`). Everything else derives from `platform`, -the PRIMARY shell: the one rooted at `root_mod`, the one `unit-test` runs, and -the one the snapshot and harness suites drive. `also_gui` adds the second beside -it and changes nothing about the first. - -A bare `zig build` with an untouched prefix also redirects the install prefix, on -two conditions and the second is not the obvious one: no shell was NAMED -(`-Dplatform=web` keeps writing `zig-out/web`, which its docs name), and nothing -else has already said where to install — no `DESTDIR`, no `--prefix`, no -`--prefix-*dir`. It cannot 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 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. - -The other three platforms are separate invocations: -`-Dplatform=gui` for `pardes-gui`; -`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=` for `pardes.wasm` -plus a vanilla DOM shell; `-Dplatform=macos` plus the `macos-app` step for -`pardes.app` wrapped around a static `libpardes.a` on a Darwin host; and -`-Dplatform=esp32p4` for the freestanding object. Any other spelling of the -combination fails on purpose rather than building something silently wrong — the -web target, the web dump, MuPDF on a freestanding platform, and tree-sitter on -the board each have a named refusal (`build.zig:292-298`). The esp32p4 target is -in fact *forced* to `esp32p4_target` rather than taken from `-Dtarget`, so a -`-Dtarget` cannot discard its CPU features, and a wrong one is still an error -rather than silently overridden. - -Because `platform` is comptime and `pardes.platform` is read from -`pardes_config`, two frontends in one build need two options modules — the gui -shell `@cImport`s an SDL the cli must not link. `ShellConfig` gathers everything -`pardes_config` carries that is a property of the BUILD rather than of one -frontend into one value, so the two modules a default build makes cannot drift -apart in any field but the one that is supposed to differ -(`ShellConfig`). `hxdiff`'s headless core and `pardes-isolate` share the -primary shell's, being the same frontend. - -`pardes-isolate` is 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 and macOS are libraries whose host owns `main()`. - -== Generated inputs - -Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a -pinned fork, lazy), FreeType, HarfBuzz (the SDL shell's text shaping, lazy, -built against that 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) — all pinned through `zig fetch` and wired in `build.zig`. The board's -`05-zig-p4` firmware toolchain is a separate sibling checkout. - -The 9P library is the one pinned dependency that is not third-party: `cloud9` -lives at `git.sr.ht/~gbrls/cloud9`, pushed as `git@git.sr.ht:~gbrls/cloud9` and -fetched from that commit's HTTPS URL, since zig has no `git+ssh` support. - -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; -working-tree `.zig` sources into the web shell's read-only archive; and eight -GLSL shaders into SPIR-V. - -The themes go straight into the build cache and are 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 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. The generator is compiled in Debug on purpose: it runs for -about 40 ms, and every core module imports what it generates, so its compile is the -first link of every cold build; ReleaseSafe cost 16 s of compile to save 47 ms of -run time, and the files it writes are byte-identical either way -(`theme_gen` in `build.zig`). - -The shaders are the only build input wanting a tool a stock machine lacks -(`glslc`), so they are also the only one whose output is committed: -`zig build shaders` refreshes each paired `shaders/prebuilt/*.spv` binary and -`.glsl` source snapshot together. `-Dprebuilt-shaders` embeds that exact pair -rather than shelling out, so `EffectCode` cannot describe different shader text -from the binary on screen; this is also what lets a gui build need nothing but a -C toolchain. - -The tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds -are incremental for the seconds-loop; release builds are the product. - -== Versioning - -There is one version string in the tree and it is in the manifest. - -```zig -// build.zig:8 -const zon = @import("build.zig.zon"); -// ...in shellOptions, build.zig:1915-1923: -o.addOption([]const u8, "version", zon.version); -o.addOption(?[]const u8, "commit", cfg.commit); -``` - -The `@import` is UNTYPED on purpose, and that is what makes it 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 there was working around. - -```zig -// src/pardes.zig -pub const version = @import("pardes_config").version; -pub const commit: ?[]const u8 = - @import("pardes_config").commit; - -// src/main.zig -const version_text = if (pardes.commit) |c| - "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n" -else - "pardes " ++ pardes.version ++ "\n"; -``` - -Both halves are comptime, so `version_text` is one string in rodata and -`pardes --version` is one write (`main.version_text`). - -`gitCommit(b)` runs -`git -C rev-parse --short=12 HEAD` at CONFIGURE -time, 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. The `-C` is not -decoration either — `zig build` may be run from anywhere, and a `git` resolved -against the cwd would cheerfully answer about a different repository. - -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; every -way of having no answer lands on the same `null` and the frontends print the -version alone. It is deliberately NOT `--dirty`: marking a dirty tree would cost -a worktree stat on every configure and — the real cost — would change -`pardes_config` on every file edit. Every module in the 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. - -The manifest is read at comptime twice, and the second read is what keeps the -one string this build cannot fold into the manifest honest. `zls_version` -(`zls_version`) has to be spelled beside `.dependencies.zls.url`, because the -semver half of it — `0.16.1-dev` — exists nowhere in the manifest, and ZLS's own -`build.zig` needs it: that file otherwise shells out to `git describe`, which -fails on a fetched package with no `.git`. So the duplication is unavoidable and -a `comptime` block beside it makes the two AGREE instead: it takes the -short commit after the `+`, takes the sha after the `#` in -`zon.dependencies.zls.url`, and `@compileError`s unless the pinned sha starts -with it. A `.zon` bump that forgets the line beside it is now a build error -rather than a `SPC l i` naming an analyser nobody linked. - -= Testing: the old program is the oracle - -Before the rewrite compiled, the prototype got a harness (`test/snapshot.zig`, -the `zig build snap` step): it forks either binary in a pty, feeds it an *event -script* (one line per input: keys, SGR mouse, resizes, sync points), and captures -the rendered grid — text, cursor, and per-cell style runs — through its own -ghostty terminal. Goldens are generated from the old binary -(`zig build snap -- --update`); the new binary must reproduce them byte for byte. -Eighteen scripts covered the checklist in Appendix A at the rewrite; ninety-five -cover it and everything since (`test/snapshots/` holds 95 `.snap`/`.golden` -pairs). All pass. - -Determinism pins: fixed workdir paths (they appear in tags), a controlled `$HOME` -with `PS1='$ '`, `LC_ALL=C`, and a grid-stability sync primitive instead of -timing guesses. - -Two of those pins were doing damage rather than work. - -== Deltas, not screens - -A capture used to restate the whole screen, so 82% of golden lines were a copy of -the line above (`test/snapshot.zig:59`), every golden carried the topbar and a tag -row, and adding one builtin word to a tagline rewrote 78 of them — 3,758 lines of -diff for a change no test was about. A capture is now a *delta* against the -previous capture of the same kind in the same script: the first is the whole -screen, the rest are only the rows that changed. The corpus went from 16,700 lines -to 5,722 and the same one-word edit now moves 337 (`src/CHANGELOG.md:9-13`). A -capture whose only change is the cursor is the empty delta its script always -meant. - -== A click names a word - -A click used to name a screen column, which is a coordinate into that same -chrome. When `Newtty`, `Joincol` and `Changelog` were added, seven scripts began -clicking the word next door. `snapshot.zig`'s own note enumerates six of them: -`tutor.snap` clicked `Grep` where it meant `Tutor`, `exec.snap`, `respawn.snap` -and `tagalign.snap` clicked `Newtty` where they meant `Del`, `find.snap` clicked -`Joincol` for `Find`, and `windowops.snap` clicked `Tutor` for `Debug` — and -`--update` blessed all of it, so 256 golden lines were green while asserting the -opposite of their script's first line (`test/snapshot.zig:817-825`). The seventh -is the changelog's: `tagbottomimage.snap` clicked blank space 176 columns from -the `Del` whose effect it asserted (`src/CHANGELOG.md:9-11`). A click may now -name the word (`press middle @Del 2`, `@Del#2` for the second pane on a row, -`@Save-2` for a column beside one), so the word is either there to be clicked or -the script fails. - -== Regeneration verifies itself - -Regeneration was the other half of that failure: `--update` captured once, -serially, and wrote whatever it saw, which is how six wrong clicks became -goldens. It now captures in parallel at the widest probe settings the harness -has and then runs the ordinary verify pass over what it wrote, so a capture that -does not reproduce is reported instead of committed — 77 s to 41 s, and the retry -machinery serial update never had (`src/CHANGELOG.md:14-16`). - -== Deviations kept after review - -Three deliberate deviations surfaced by the oracle and kept. - -The greeting `ls` waits for the exact OSC 133 B input mark after the real resize; -the prototype raced bash's startup and won only by allocator luck. Shells without -prompt integration omit that cosmetic greeting rather than guessing. Commands -which create a fresh shell are owned by its terminal pane until the host reports -the actual prompt capability, then wait for the same mark when it exists. - -Dump files compare with base64 pty history elided: it encodes prompt-redraw -micro-timing, not state, and the cleaned text fields are the contract. - -Typed insert runs do not survive a dump replay. They are an overlay, not pty -bytes, and the prototype's replay viewer had the same semantics. - -== The shell suites - -Shell correctness is no longer out of scope, and that is the biggest change to -this section since the rewrite. `web-snap` and `web-e2e` drive headless Chromium -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). 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 -editing a file against editing the same text in a pty — which is what the -per-pane ghostty-vt `Terminal` buys. - -What remains untested by a snapshot is the last hop — that vaxis diffs correctly -onto a real terminal — plus each shell's inline unit tests (the FreeType atlas -raster, the trackpad and rotation maths, the gamepad replay). - -= Style - -TigerStyle, plus house rules proven in the prototype: imperative and flat; one -big `update` dispatch, not handler objects; no one-line helpers — inline the -four-line scan; assert invariants at entry (`assert(vsum > 0)`); static -allocation at init, arenas per frame. - -The rewrite used source size as a pressure toward direct code, and that remains -useful when a refactor deletes duplicate policy or state. A checked-in line-count -inventory does not: it goes stale whenever a pane kind, backend, or generated -asset moves. Measure the current tree when making that comparison; keep this -document about ownership and invariants that should survive the next edit. - -Two things that number is not. It is not one program's worth of growth — the -macOS, web and firmware shells and the PDF and language work are several products -sharing a core. And it is not licence: the rule that survives is the local one, -that a change should leave the file it touches no longer than it found it. - -= What is deliberately absent - -No render abstraction over the shells: the surface *is* the abstraction. No -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 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. -The keymap is `src/config.zig`, compiled in, where a wrong binding is a compile -error rather than a silent no-op. - -#heading(numbering: none)[Appendix A: feature parity checklist] - -From the prototype survey; every line is covered by at least one of the event -scripts in `test/snapshots/` — the original eighteen (boot, tty, edit, scroll, -modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, -ttyonly, syntax, fileedit, images), and seventy-seven more added since for -everything below that the prototype never had. - -*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not -per column); global topbar; -per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only -the source (cursor row kept visible); `Newcol` takes width only from the source -column and cannot resize any unrelated column; dying pane's weight absorbed by one -sibling; emptied column hands width to a neighbor; border-drag resize on a -pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints; -corner grab moves exactly two boundaries; -move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move -pane to new column; Ctrl-w h/j/k/l directional focus; body-normal Esc hops to -the pane you were in before this one, alternating between two (the same -builtin as SPC j j); -`--tty` single-pane mode. - -*Mouse.* Left: select (block, stays highlighted after drag, pins cursor, -enters normal), click clears, tag-row click enters tag edit, scrollbar -click scrolls up-to-row (right button: down), border/move drags. Middle: -execute — no-drag expands to file-ish word (alnum `.-+/:@_~`); builtin or send -to shell; does not focus. Right: look — peel `:NNN`, resolve against the -clicked pane's dir (`/proc/pid/cwd`, or a file's dirname) and, if that fails, -against every other live pane's, most-recently-focused first (the jump stack -backwards, duplicate dirs skipped; absolute words try one dir and stop) so a -relative name is openable from any window that can see it; dir → shell + `ls` -in source column (dedup by cwd), file → file pane at line (dedup by path, -first doc opens left column and may evict a lone pristine shell; later docs -split below the existing doc — an output pane, `+Search`/`+Help`, is not a -doc for either half of that rule: it never claims a column, it splits below -whatever pane asked for it, and nothing splits from it), image ext → image -pane, `.pdf` → PDF pane. Ctrl+left is goto-definition, the one chord borrowed -from every editor with a language backend. Two spellings the word expansion -has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather -than opening as a file, and `@p7:10:5` addresses a live pane by number for the -things — terminals, output buffers — that have no path to name. -Middle+left chord: kept -left selection appended as trailing CLI argument. A stationary pointer gets a -delayed, theme-derived highlight of the exact side-effect-free selection that -Look would expand; it neither focuses nor installs that selection, and pointer -leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched. - -*Modes.* normal: helix motions (`h j k l w b e W B E 0 gl ^`, `f F t T` and -`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`), -insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth -mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS -up to 64 (`C`/`Alt-C` copy, `s`/`S` select and split by regex with a live -preview, `Alt-s` split on newline, `,`/`Alt-,` keep and remove the primary, -`)`/`(` rotate, `Alt--`/`Alt-_` merge), operators (`d c y p P R u U`, `Alt-d` -delete-noyank, `J`, `>`/`<`, `~`/`` ` ``/`` Alt-` ``, `Ctrl-a`/`Ctrl-x`, -`Ctrl-c` comment-toggle), textobjects and surrounds under `m` -(`mm mi ma ms mr md`), the `]`/`[` pairs (`]p ]d ]D ]`), `/` with `n`/`N`, -`|` to filter the selection through a command, `:` for the tag as a command -line, and Enter=look Tab=execute at cursor. Since the helix motion model -landed, a traversal motion SELECTS the range it crossed — which is why there is -no verb+noun grammar and why `i` after `w` types at the selection's start. insert: -click-and-type; terminals get splice runs (shift right, never overwrite; -absolute-row anchored), files get real edits. tty: raw pty forwarding -(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable, -promptClickMove on entry, prompts visible (hidden in the other modes via -OSC 133). `y` fills the yank register and `p` pastes it; neither touches the -system clipboard, which is helix's five words — `SPC y/Y/p/P/R`, out via -`set_clipboard` and back via `read_clipboard` — and nothing else, so a delete -cannot clobber what the desktop was holding. A paste from an outer terminal -arrives bracketed, as one `paste` event. - -*Tag.* Compact name/status prefix + editable command tail. File names support -staged edits: Enter commits a new buffer save target, Escape cancels, and no -disk rename or write happens until explicit Save. Terminal cwd and image/PDF -status stay generated. Save leads the tail of every pane holding text of its own: -`Save Tty Collapse Del` for a file or an output buffer, -`Tty Save Mode Filter Collapse Del` for a terminal, and `Tty Collapse Del` -for an image. PDFs use `Tty PdfSections PdfTint Collapse Del`, without tint status text. -The word that closes the thing is last on every tagline, so overshooting the -click before it cannot destroy anything. -`Collapse` toggles a pane between its tagline alone and its expanded height; -hidden body contents and running terminals are retained. -An unsaved file has `*` after its name; an image tag -reports -`img petscii: palette: ascii: ` -before the ordinary tail (its renderer toggles are builtins under `SPC t -p/l/a`). Topbar: -`Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill` — -editable by left click, with keyboard command navigation and Exec/Look gestures. -An additional editable tag in each column supplies local New, Tty, Find, -Grep and Joincol commands; it is always shown, as acme's column tag is. -Colors and Crt left it for their leader paths. - -*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics, -OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the -nushell/helix regression), a default-on pane-local Filter which uses ghostty-vt's -theme-derived 256-colour generation and keys rendered cell foreground/background -truecolour and OSC overrides through that palette without mutating emulator state, -greeting `ls`, auto-follow output unless -navigating. File: line-number gutter (fixed width), tree-sitter highlights -(c/cpp/zig minimal tier; 26 grammars full tier; re-highlight on edit, -visible-range first), Save, open-at-line, undo/redo. Image: zstbi decode, -kitty graphics when available, petscii matcher fallback (C64/terminal -palettes, ascii glyph set toggle). PDF (`-Dmupdf`, native default on): MuPDF -rendering as one continuous page strip, real text search, mouse text -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. -Embedded PDF links use the ordinary right-click Look gesture. Hovering one -shows an accent highlight and a pointing hand in graphical frontends; dragging -still selects text. Internal links reveal their page and anchor. When the -annotation's visible label resolves to a different Look location, a `+Links` -output lists the visible destination and the embedded destination for you to -choose with Look. Internal destinations in that list use `document.pdf:page`. -Labels that are not locations follow the embedded link directly; identical -destinations open once. Links use the same supported URL and file-location -rules as Look. -Tutor: embedded text as file pane. - -*Language.* The native host snapshots each request and runs it outside the UI -loop. Every language's server, Zig's zls included, is a child process the -JSON-RPC client speaks to. 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, -colors lifted from the helix editor's own theme), dark, and the acme-light one -named simply `acme` — then -everything `tools/gen_themes.zig` exports at build time out of the vendored -helix `.toml` and zed `.json` sources in `vendor/themes`, which is all 214 helix -ships plus zed's 11, sorted by name. Names are unique by construction: helix's -own acme is vendored as `acme_helix.toml` so it cannot collide with ours, and -every zed theme takes a `_zed` suffix for the same reason. helix and dark leave -a child's ANSI palette native; acme and the zed exports resolve it onto -the page so shell output stays readable on a light one. A theme owns the page, -the tag bar, the gutter, the move box and the SELECTION: `sel_bg`/`sel_fg` are -one pair per theme, and the three per-button tints and the dimmed extra cursors -are mixed off it, so what stays fixed is the distinction between buttons and not -the colours. `NextColor` browses the ring one -step at a time — at 228 it is no longer how you REACH one — -`Theme ` jumps to one and `Themes` (`SPC t t`) lists them all into an -output buffer whose rows are those very commands — execute a row (Tab, middle -click) and the theme goes on; n/N select such a row WHOLE, since a command -line holds no place to pick out of it — the third grain of that motion, the -other two being one stop per ROW in a results list (the location at its head, -never the matched text after it) and every look-able word in free text. Native -shells also accept `ThemeFile `: one complete ZON `Theme`, loaded at -runtime and, where document watches are available, watched with the same -parent-directory/rename-over semantics. `DumpThemes` materializes the compiled -ring under `/themes/builtin/`, providing the schema and a copyable -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 -`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. 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. - -*Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel, -scrollbar, Colors/NextColor, and link-LOOK opening a new -tab (new — the prototype has no web link handling). One-finger touch is -deliberately complete by itself: tap = LOOK; drag past a small slop = natural -scroll, with no LOOK on release. A build-generated read-only archive lets LOOK -open the current contents of tracked or new/nonignored Pardes `.zig` files despite the web -shell having no host filesystem. The published launcher dump is captured from -a running Pardes TTY after -`git ls-files --cached --others --exclude-standard -- '*.zig' | sort`, so its -complete terminal listing is the same tracked-plus-new/nonignored set the user -can open. The freestanding module carries no host -libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into -the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains -tap-LOOK/drag-scroll, but finger-down on -a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture -for its lifetime, keeping layout drags out of the scroll heuristic. Touch -input translation and scroll/tap state live entirely in the JavaScript shell. -The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V), -with words shaped by HarfBuzz so a font's ligatures span their cells -(`Ligatures off` draws every cell as its own glyph). -The browser exposes each cell as selectable, inspectable text and applies the -surface styles with CSS. The browser `.snap` harness drives real Chromium touch -input and reads both text and per-cell styles from the DOM renderer's packed -surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships -no gamepad input. - -*Board (ESP32-P4).* A 56×14 grid by default (`-Desp32p4-cols`/`-rows`), 384 KiB -of heap, no ptys, no tree-sitter, no MuPDF, no filesystem of its own, and no -embedded source table. vaxis unmodified over a byte sink the firmware supplies; -DEC mode 2048 for resizes. `Peek`, `Poke`, `Hexdump` (eight bytes per row) and -`Gpio` exist only here, and `Gpio` goes through the host vtable because the pad -sequence belongs to the firmware that already tests it against ESP-IDF's headers -on the die. -- cgit v1.3