diff options
Diffstat (limited to 'docs/design.typ')
| -rw-r--r-- | docs/design.typ | 406 |
1 files changed, 136 insertions, 270 deletions
diff --git a/docs/design.typ b/docs/design.typ index 9a92f7ee..8a5b9381 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1,9 +1,4 @@ -// Diagrams come from cetz, which is the one thing here that is genuinely -// painful to hand-roll. Pinned to the version in the local package cache so -// this document builds offline; `typst compile docs/design.typ docs/design.pdf` -// must never need the network, because the PDF it produces is a test fixture -// (src/pdf.zig and src/pdf_pane*.zig open it, and `zig build mupdf-check` -// renders and searches it). +// docs/design.pdf is a retained test fixture, not regenerated by normal builds. #import "@preview/cetz:0.5.1" #set page(paper: "a4", margin: (x: 1.5cm, y: 1.8cm), columns: 2, numbering: "1") @@ -15,10 +10,6 @@ #show heading.where(level: 2): set text(size: 9.2pt) #show heading.where(level: 3): set text(size: 8.8pt, style: "italic") -// Listings are styled NATIVELY rather than through a package. codly is the -// obvious choice and the cached 1.2.0 does not compile under typst 0.15 — it -// still calls the pre-0.13 `pattern` — and a document that cannot be rebuilt is -// worse than one with plainer listings. Six lines buy independence from that. #show raw.where(block: true): it => block( width: 100%, fill: luma(246), @@ -33,9 +24,6 @@ #set figure(gap: 0.55em) #set table(stroke: 0.4pt, inset: 0.35em) -/// A figure that spans BOTH columns, for the diagrams and listings that will -/// not survive being folded into 8 cm. Floats to the top of a page, which is -/// where a reader expects a wide figure to be. #let wide(caption: none, body) = place( top, scope: "parent", @@ -44,9 +32,6 @@ figure(body, caption: caption), ) -/// Diagram labels are code more often than they are prose, and the inline-raw -/// rule above sizes for body text. Every canvas below is wrapped in this so a -/// symbol name inside a box does not outgrow the box. #let diagram(body) = [ #show raw.where(block: false): set text(size: 5.9pt) #body @@ -70,8 +55,8 @@ prototype had three parallel implementations of one editor. Two seams carry that. Outward, the core is a state machine over plain values: `Event` in, `Surface` and a queue of `Effect` out, and nothing that cannot - be said in those types exists in pardes. Downward, `host.VTable` is - twenty-one optional function pointers, every null one answered in-process, + be said in those types exists in pardes. Downward, `Host.VTable` is + optional function pointers, with in-process fallbacks, so the zero-method host is both the test harness and the browser. A session can also be a daemon: the core keeps the ptys and the disk and N frontends carry only a screen, a keyboard and a clipboard. @@ -106,7 +91,7 @@ instance's dump is a first-class feature of the one application, and the web shell is that application with an embedded dump and `spawn` left unanswered. Same core, no viewer fork. -The other acme mechanism is a control filesystem, `src/acmefs.zig` +The other acme mechanism is a control filesystem, `src/fs.zig` (@fs). == One core, five shells @@ -135,12 +120,12 @@ The five, and what each one actually is: the terminal (libvaxis); a native SDL3 window (the steamdeck); the browser (a freestanding wasm core driven by vanilla JavaScript and rendered as HTML/CSS); a native macOS app (an AppKit and CoreText shell over a static `libpardes.a`); and an ESP32-P4 microcontroller, a -freestanding riscv32 *object* that the `zig_p4` package links beside its own +freestanding riscv32 *object* that the sibling `05-zig-p4` toolchain links beside its own `_start`, linker script and UART driver (`src/esp32p4.zig` header; the `pardes-esp32p4` object `build.zig` emits). `pardes.Platform` is `enum { tty, gui, web, macos, esp32p4 }`. -Native shells share `shell_bin`'s OSC 133 startup snippets, but not their +Native shells share `host_io.Shell`'s OSC 133 startup snippets, but not their files. Each host owns a private `mkstemp` pair for its lifetime, writes and closes both before the first fork, passes those unpredictable paths directly in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS @@ -154,19 +139,17 @@ files. session can carry a terminal and an SDL window at the same time and outlive both. `main.nativeMain` dispatches `--detach` before it switches on the platform, because it is not a shell: the tty and gui builds can both be asked -for one. The two flags together are refused there rather than resolved by -declaration order, and so is `--fs` beside either of them — only a LOCAL session -mounts the control filesystem, so `--fs --detach` used to be parsed, stored and -served by nobody. Both flags take `=name` and never a separate word, so +for one. The two flags together are refused. Local and detached sessions both +serve 9P by default. Both flags take `=name` and never a separate word, so `pardes --attach README` opens `README` in a fresh session instead of attaching to one called `README`. See @detached and `docs/detached.md`. = The seam <seam> #wide(caption: [The core/shell seam. `Event` is the only way in; `Surface` and a -queue of `Effect` are the only ways out. `host.VTable` is how an `Effect` +queue of `Effect` are the only ways out. `Host.VTable` is how an `Effect` reaches a resource, and every method a host leaves null is answered by -`host.Fallback` inside the same process.])[ +`host_io.Fallback` inside the same process.])[ #diagram[ #cetz.canvas(length: 0.995cm, { import cetz.draw: * @@ -212,15 +195,15 @@ reaches a resource, and every method a host leaves null is answered by // ---- the vtable ---- rect((6.0, -1.0), (11.6, 0.4), name: "vt") - content((8.8, 0.14), text(7.0pt)[`host.VTable` --- 21 optional methods]) - content((8.8, -0.32), text(6.0pt)[`push_` #sym.arrow.r every host, returns nothing]) - content((8.8, -0.68), text(6.0pt)[`pull_` #sym.arrow.r exactly one host answers]) + content((8.8, 0.14), text(7.0pt)[`Host.VTable` --- optional callbacks]) + content((8.8, -0.32), text(6.0pt)[one host owns each core]) + content((8.8, -0.68), text(6.0pt)[null callbacks use core fallbacks]) line((8.8, 0.9), (8.8, 0.4), mark: (end: "stealth")) line("vt.east", (13.3, 1.5), mark: (end: "stealth")) // ---- fallback ---- rect((0, -1.0), (4.3, 0.9), name: "fb") - content((2.15, 0.62), text(7.0pt)[`host.Fallback`]) + content((2.15, 0.62), text(7.0pt)[`host_io.Fallback`]) content((2.15, 0.18), text(6.0pt)[every null method, answered here:]) content((2.15, -0.18), text(6.0pt)[a virtual filesystem over the]) content((2.15, -0.52), text(6.0pt)[embedded source, a virtual]) @@ -316,7 +299,7 @@ pub const Effect = union(enum) { theme_file: struct { generation: u32, on: bool }, dump_themes: struct { pane: u8 }, - fs_reply: acmefs.Reply, + fs_reply: filesystem.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, @@ -408,27 +391,24 @@ for a pty; everything else queues O(1) effects per event, and the input queue holds at most 64 events per pump, so 128 leaves two effects per queued event. A build with no terminal panes has no pty to write to at all. -== `host.VTable`: who serves the core +== `Host.VTable`: who serves the core -The seam itself is one struct of twenty-one optional function pointers -(`host.VTable`): the `std.mem.Allocator` shape, and the generalization of -two vtables this codebase already grew on its own: the tty shell's -terminal-query hook, which this replaced, and `macos.Runtime`, which survives as -the C ABI that shell's host still enters through. +`host_io.Host` holds a context pointer and optional callbacks. macOS enters +its native shell through the separate `Runtime` C ABI. ```zig pub const VTable = struct { /// The ONLY place the process may sleep. - pull_wait_input: ?*const fn ( + wait_input: ?*const fn ( ctx: ?*anyopaque, timeout_ms: u32, ) void = null, - push_present: ?*const fn ( + present: ?*const fn ( ctx: ?*anyopaque, surface: *const pardes.Surface, ) void = null, // ... - pull_tty_taken: ?*const fn ( + tty_taken: ?*const fn ( ctx: ?*anyopaque, pane: u8, ) bool = null, @@ -437,17 +417,16 @@ pub const VTable = struct { ``` A null method is not an error: the core substitutes a default backed by ordinary -data structures in the same process (`host.Fallback`). A `Save` lands in a real +data structures in the same process (`host_io.Fallback`). A `Save` lands in a real file under the tty host and in `Fallback.files` under a host that never wrote a filesystem method, and every path above that behaves identically. Two consequences were taken on purpose. The zero-method host IS the test harness: a `Host{}` is a complete, deterministic, in-process pardes with a virtual filesystem, a virtual clipboard and silent ptys. And `Fallback` lives on the -`Pardes` instance rather than on the host, so N cores driven by one fan-out host -each keep their own state and can run in parallel. +`Pardes` instance rather than on the host, so cores keep independent state. The fallback filesystem is not empty. It is pardes's own source, embedded -(`src/source_manifest.zig`), with `files` holding only what this session WROTE, +(`src/fs.zig`), with `files` holding only what this session WROTE, so a Save shadows the built-in copy and reading it back returns the edit. That is what makes a host with no file methods a usable pardes rather than one staring at an empty buffer. @@ -472,46 +451,11 @@ platforms, which is exactly the pair `main.zig` accepts `--attach` for: the same question asked at the command line instead of in a tag. A capability that a build cannot serve should not be a word that build offers. -=== The naming rule is compiler-enforced +=== Callback ownership -Every method name says how a fan-out must route it, and `Fanout.isPull` makes a -wrong name a compile error rather than a silent push. - -#wide(caption: [`host.Fanout.isPull`. The failure of a forgotten `pull_` is -invisible in every unit test and obvious only to the user: one Ctrl-V pasting -twice. `Fanout.all` synthesizes one wrapper per field, so adding a method to -`VTable` needs no code in the fan-out at all.])[ -```zig -/// How to route a method, read off its own name. A method that is neither -/// is a COMPILE ERROR rather than a silent push, because the failure of a -/// forgotten pull is invisible in every unit test and obvious only to the -/// user: one Ctrl-V pasting twice. -fn isPull(comptime name: []const u8) bool { - if (std.mem.startsWith(u8, name, "pull_")) return true; - if (std.mem.startsWith(u8, name, "push_")) { - if (Method(name).return_type.? != void) @compileError("Host.VTable." ++ - name ++ " reaches every host, so it cannot return a value: whose answer would it be?"); - return false; - } - @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++ - "or pull_… (exactly one host serves it, because there is one of whatever comes back)"); -} -``` -] - -`push_` reaches every wrapped host and returns nothing — a push with an answer -would have N answers and no way to pick one. `pull_` is served by exactly one -host, because there is one of whatever comes back: one value, one sleep that -ends, one `Event.paste` for one Ctrl-V, one `lsp_resp` per request id. - -The six `pull_` methods are therefore exactly the six places a single answer -exists: `pull_wait_input` (the one place the process may sleep), -`pull_tty_taken`, `pull_gpio_toggle`, `pull_read_clipboard`, `pull_lsp` and -`pull_pipe`. `pull_tty_taken` is a pull and not a pushed fact because the -`execute` that asks — has a program taken this pane's tty? — must choose a -destination inside its own update, and effects drain after; a pushed fact would -mean every host probing every pane's processes every frame to answer a question -asked when a human middle-clicks a word. +Each core has one host. The detached host explicitly broadcasts shared state +and routes clipboard reads and link opening to the originating frontend. +There is no name-based fan-out layer. == The frame, as a frontend writes it @@ -519,7 +463,7 @@ asked when a human middle-clicks a word. pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.pull_wait_input) |f| + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); @@ -527,12 +471,12 @@ pub fn pump(p: *Pardes, h: Host) !void { // A quitting frame has already freed // what it would draw. if (p.quit) return; - if (v.push_poll_frame) |f| f(h.ctx); + if (v.poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render( p.frame_arena.allocator()); - if (v.push_present) |f| f(h.ctx, surface); - if (v.push_post_present) |f| f(h.ctx); + if (v.present) |f| f(h.ctx, surface); + if (v.post_present) |f| f(h.ctx); // ... } ``` @@ -543,12 +487,12 @@ then every queued event, to completion; then every effect, to completion, including effects `perform` queued; then one arena reset and one render; then present, then post-present. -Animation time is not spent in here. `pull_wait_input` was told how long it may +Animation time is not spent in here. `wait_input` was told how long it may sleep, and a display clock wakes faster than that on input, so only the host knows when a real frame interval has passed. Each spends it by handing back one `.tick`. -`push_post_present` is split from `push_present` because it must observe a frame +`post_present` is split from `present` because it must observe a frame the user has actually seen: panel-presentation acknowledgement and pointer refresh both depend on that, and a hook that ran before the pixels landed would acknowledge a frame that was never shown. @@ -581,14 +525,10 @@ The core's one `pointerOperand` primitive owns click-word expansion and is share verbatim by right-click and the delayed hover preview; that policy stays beside input because it also observes live pane selections and wrapped grid coordinates. -Pane implementations are similarly flat and direct: `file_pane.zig`, -`term_pane.zig`, `image_pane.zig`, `output_pane.zig`, and `pdf_pane.zig` own -their kind-specific storage and operations. `pardes.zig` keeps the layout, input -dispatch, cross-pane invariants, and the small calls joining those modules. -There is no pane vtable or callback layer; the kind is already plain data, so a -direct switch/call is the shortest boundary. The large end-to-end PDF cases live -in `pdf_pane_integration_test.zig`, keeping pane-specific fixtures and raster -assertions out of that core file as well. +`panes.zig` keeps each pane kind's storage and operations together. +`layout.zig` owns placement and presentation state. `pardes.zig` handles input +and cross-pane state directly, without a pane vtable. Integration fixtures live +in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`. = State @@ -600,57 +540,30 @@ grow with what you open; everything else is sized at init. ```zig Pardes - ncol + col_weight[6], col_terms[6][16], col_n[6] - panes: [16]?*Pane // slot array; id = index - active: usize - drag: Drag // none | border_v | - // border_h | move | - // tag | select - settings: runtime_config.State - rects: [16]Rect // where each pane - // landed, this frame - panel_tracks: [16]?Track // live panes, - // serial-guarded - presented_panel_tracks:[16]?Track - closing_panel_tracks: [16]Track // dense visual - // tombstones, no owner - presented_cells + panel_cell_diffs + ncol + col_weight[6], col_panes[6][16], col_n[6] + panes: [16]?*Pane + active + drag + config.Runtime + rects: [16]layout.Rect + presentation: layout.Presentation effects: [limits.effect_cap]Effect + head/len in_q: [64]Event + head/len - fallback: host.Fallback // per instance - fs: acmefs.State // inert until --fs + fallback: host_io.Fallback + fs: fs.Namespace Pane - tag: TagLine // live prefix (cwd/path) - // + editable tail - vt, stream: ghostty-vt Terminal and its stream — - on EVERY pane, not just terminals, - which is what lets the same keys and - the same parity suite drive a file - and a shell - file: ?file_pane.State - image: ?image_pane.State - pdf: PdfSlot // payload presence is the - // kind; none = terminal - vweight: f32 - mode: enum { normal, insert, tty } - cursor: absolute body position // rides the - // scrollback, not the screen - msel/vsel + sels[63] + nsel // the primary - // range, and up to 63 more - ovl: ?term_pane.EditBuffer // ONE typed run, - // anchored to an absolute row - undo: two stacks, not one — term_pane.Snapshot - history for an edit buffer, and - file_pane.State history for file content + 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 -file_pane.State = path + bytes + line index - + Syn (tree-sitter bytes) -image_pane.State = decoded RGBA + petscii cache -pdf_pane.State = MuPDF document + continuous - layout + search/selection/outline - + bounded per-page raster relay - + frame placement decisions +Terminal.State = VT + stream + replay + reply +File.State = path + bytes + line index + syntax + undo +Image.State = decoded pixels + render cache +Pdf.State = document + layout + raster cache + search ``` `MAX_PANES` is 16 and `MAX_COLS` is 6 (`pardes.MAX_PANES`, `pardes.MAX_COLS`). Sixteen panes @@ -777,11 +690,11 @@ does not touch. == Runtime settings are one table -User-settable runtime choices are one plain `runtime_config.State`: booleans, +User-settable runtime choices are one plain `config.Runtime`: booleans, theme index, owned bounded shell/font strings, requested/effective font facts, one panel-transition enum, and scene-effect booleans -(`runtime_config.State`). A compile-time `settings` array -(`runtime_config.settings`) generates each setting builtin and the rows of the +(`config.Runtime`). A compile-time `settings` array +(`config.Runtime.settings`) generates each setting builtin and the rows of the single `Config` query. It has no callbacks and no parallel query registry to drift from it. @@ -871,7 +784,7 @@ selectable, yankable and executable but READ-ONLY: every edit op measures from `tag_tail` is a fixed `[max_tag_tail]u8`, and the bound IS the storage (`limits.max_tag_tail`): every writer — `appendTag`, `tagInsert`, -`restoreDumpTail`, the acmefs `tag` file — refuses input that does not fit +`restoreDumpTail`, the 9P `tag` file — refuses input that does not fit rather than truncating it. The schema limit and the buffer therefore can never disagree, which is what lets a dump reader reject data before copying it into a pane. @@ -884,10 +797,10 @@ clicking a tag never changes a pane's mode. == Eleven transitions -`panel_animation.zig` is backend-neutral data and math: the transition +`layout.zig` is backend-neutral data and math: the transition vocabulary, easing, exact endpoint progress, stable per-cell noise, and a POD track. `Transition` has twelve members counting `off` -(`panel_animation.Transition`), with explicit numeric values because they +(`layout.Transition`), with explicit numeric values because they cross both GUI shader ABIs — GLSL receives the enum in an instance `uvec4` and the Core Image kernel receives it as a float, so spelling the numbers keeps a source reorder from changing pixels. @@ -963,7 +876,7 @@ An `.ascii` diff is a `{ from: u8, to: u8 }` pair, and the core composes it by incrementing or decrementing the printable byte. Short walks move one value per frame; a longer walk is crossed by eased character skips and finishes within `ascii_max_movement_frames`, which is 12 -(`panel_animation.ascii_max_movement_frames`). Frame +(`layout.ascii_max_movement_frames`). Frame zero is the exact old byte and the endpoint is exact, so an intermediate frame is always valid UTF-8. `Track.frame_count` carries the core-computed duration for these data-dependent effects — zero selects the effect preset, and the ASCII @@ -997,11 +910,9 @@ DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes neither effect family. -`EffectCode <effect>` writes the actual backend math, host submission, and -shader or grid source segments embedded by the build into an ordinary output -pane. This makes the implementation inspectable after installation and makes -sharing explicit: several builtins can quite honestly print the same shader with -different uniform bits. +`EffectCode <effect>` lists the current backend's build-embedded source paths +under `/virtual`. Look opens each full file without a source checkout. +Shared implementations share paths. == The ASCII fast paths @@ -1039,7 +950,7 @@ configuration — 40×12, no tree-sitter — which was the largest single item t `\t`, `\r`, the C0 controls and DEL are excluded by the range test and keep their existing handling. -The second is `file_pane.fitEnd` (`file_pane.fitEnd`), which decides +The second is `panes.File.fitEnd` (`panes.File.fitEnd`), which decides where a soft-wrapped row breaks. It asks `modal.nextGrapheme` and `graphemeDisplayWidth` once per character, and a 640-column line asks 640 times. @@ -1133,8 +1044,8 @@ from `src/detached/server.zig:34-59`.])[ row(-0.92, text(5.6pt)[`read_clipboard`'s answer is not a reply message: it comes back as an ordinary `Event.paste`]) row(-1.40, text(5.6pt)[the gaps `0x13`..`0x17`, `0x1e` were `output` `eof` `lsp_resp` `pipe_resp` `file_changed` `tick`: deleted, not renumbered,]) row(-1.78, text(5.6pt)[when the daemon took the disk --- a decodable `output` let an attached peer forge a pane's text]) - row(-2.16, text(5.6pt)[never on the wire: `pull_wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s]) - row(-2.56, text(5.6pt)[that answer their own caller, and `push_fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for]) + row(-2.16, text(5.6pt)[never on the wire: `wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s]) + row(-2.56, text(5.6pt)[that answer their own caller, and `fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for]) }) ] ] @@ -1159,10 +1070,8 @@ detach, kill the terminal, attach from another one, and the build that was running in pane 3 is still running and has been scrolling into the core the whole time. -Of the twenty-one `VTable` methods, the detached core's `Session` implements -seventeen and leaves four null: `push_post_present`, `pull_gpio_toggle`, -`pull_lsp`, `pull_pipe`. It mounts its own `/dev/fuse` and polls it in the same -`poll(2)` as its frontends, so `--fs` works in a daemon and needs no thread. +The detached core also serves the default 9P socket. Its event loop drains +filesystem transactions alongside frontend and terminal input. Nothing blocks indefinitely, and that property is what a detached session is *for*. Every descriptor is non-blocking; the single `poll(2)` is the only place @@ -1238,17 +1147,10 @@ path, because neither of them writes anything. effects, because it is not an effect the session performs on the world: it is one frontend being told it is done. -Four `VTable` methods are named in the file with their reasons for *not* being on -the wire. `pull_wait_input` IS the server's poll loop. `push_poll_frame` and -`push_post_present` carry no information — `frame` already arrives exactly once -per pump at the same place in the order, so two more messages per frame per -client would say nothing the frame does not. `pull_tty_taken`, -`pull_gpio_toggle`, `pull_lsp` and `pull_pipe` are answers the caller waits for -or work dispatched off the loop, and a round trip inside `update` is the one -thing this transport must never do. `push_fs_reply` cannot be a broadcast at all: -the transport that asked is the one holding the request, so with N frontends, -N−1 would receive the answer to a request they never made — which is why the -acme mount stays in the detached process and `Event.fs_req` has no `ClientTag`. +Host polling, presentation, process queries and worker dispatch stay local to +the session owner. They are not frontend wire messages. The owner also serves +9P: each filesystem reply returns to its requesting connection, not to attached +frontends. `Event.fs_req` therefore has no `ClientTag`. === The codec is architecture- and build-neutral @@ -1272,11 +1174,8 @@ by a build with nowhere to put it. `version` is a `u16` checked on connect and refused loudly, because two builds of pardes are routinely on one machine — `zig build` replaces the binary under a running session — and a frontend decoding another version's frame layout would -paint garbage and blame the terminal. `ClientTag` is exhaustive on purpose, which -is the opposite of `fuse.zig`'s `Opcode`: there a newer *kernel* adds opcodes, -and a non-exhaustive enum is the only way to receive one without undefined -behaviour, whereas here both ends are pardes and an unknown tag is a corrupt or -hostile stream. +paint garbage and blame the terminal. `ClientTag` is exhaustive: both ends are +pardes, and an unknown tag is not a valid message in this protocol version. `max_payload` is 16 MiB, derived rather than chosen: a full frame of the largest grid this protocol admits (512×128) at a worst case of one run per cell is @@ -1386,14 +1285,13 @@ in order to. == An object, not a module `-Dplatform=esp32p4` emits ONE freestanding riscv32 object exporting the C ABI in -`src/esp32p4.zig`; the `zig_p4` package links it beside its own `_start`, its +`src/esp32p4.zig`; the sibling `05-zig-p4` toolchain links it beside its own `_start`, its generated linker script, and its UART driver (the `pardes-esp32p4` object `build.zig` emits). Not an executable, because the entry point is over there. Not a library, because `addLibrary` bundles a `compiler_rt` the firmware already has. -And not a module exposed through `build.zig.zon`, which is what it was first and -is the interesting part. A dependency in the OTHER direction was built and +Historically, it was a module exposed through `build.zig.zon`. A dependency in the OTHER direction was built and reverted: nesting this package's roughly 30-package graph under `zig-p4`'s broke every build in that repo, not just the firmware one. `std/Build.zig:2091` exceeded its @@ -1401,18 +1299,16 @@ exceeded its seven cached tree-sitter versions failed to compile because their `build.zig` uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files into a repo whose entire claim is that Zig is its only dependency. This direction -is free: `zig_p4` declares no dependencies at all, so it enlarges nothing here, -and its `build()` early-returns when it is not the root. +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 seam stays bytes over eight C functions, because bytes are the right seam for -a serial line and because that is the arrangement the board was measured -through. `-Desp32p4-firmware` adds the other half in this tree — the firmware -executable rooted at `src/esp32p4/app.zig`, the flashable image, and the steps -that write it to a board and talk to it — and the image it produces is -byte-identical to the one the toolchain repo produces from the same sources. It -is opt-in and not out of timidity: `esp32p4.firmware()` reads ESP-IDF's register -headers at CONFIGURE time and exits non-zero when there is no checkout, so a bare -`-Dplatform=esp32p4` must not call it. +The C ABI carries terminal bytes. After building the object here, `zig build +-Dpardes` in `../05-zig-p4` links `src/esp32p4/app.zig` into the firmware image. +That toolchain needs ESP-IDF register headers; the local object build does not. +The standalone GPIO 9P image instead uses `zig build +-Dapp=../02-pardes-code/src/esp32p4_9p.zig` there. It does not link the editor: +`src/esp32p4_gpio.zig` holds its fixed namespace and `src/esp32p4_9p.zig` drives +the UART protocol loop. The object is also the compile probe. Rooted at `src/esp32p4.zig` it drags the whole core through the riscv32 backend by actually calling it, so `llvm-size` on @@ -1430,12 +1326,14 @@ any other input, because firmware has no `TIOCGWINSZ`. == The memory budget is one table -`src/limits.zig` is every board-shaped capacity in one place. These numbers used +`src/memory.zig`'s `limits` contains the board-shaped capacities. These numbers used to be nine `platform == .esp32p4` tests scattered across nine files, each one a separate place to forget — and they are not nine decisions. They are ONE decision, how much memory this build is allowed to spend, taken nine times where no reader could see the total. +The original budget table below is historical; `memory.limits` is authoritative. + ```zig /// `board` is the ESP32-P4 firmware's budget: /// a 384 KiB heap and a 240 KiB chunk of L2MEM @@ -1519,7 +1417,7 @@ pub const arena = struct { ] What does NOT belong in this table is capability switches. `terminal_panes`, -`board_memory.enabled`, `hosted` and `font_picker` answer "does this build have +`builtins.Board.enabled`, `hosted` and `font_picker` answer "does this build have the thing at all", which is a question about the platform and not about a budget, so they stay next to the thing they gate. @@ -1554,84 +1452,58 @@ megabytes against a 1.5 MiB partition (`build.zig:298`). MuPDF is refused for th same reason (`build.zig:297`). The board gains four words nothing else has, all gated on -`board_memory.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and +`builtins.Board.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and `Gpio`. Each takes an address or a pin, so none can have a leader path — a key path names a builtin and can never carry an operand. `Gpio` is the one that goes through the host seam (@seam) rather than reaching the registers directly, and -`pull_gpio_toggle`'s comment says why: driving a pad correctly is not one +`gpio_toggle`'s comment says why: driving a pad correctly is not one register. It is the IO MUX function select, the GPIO matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's own headers on the die; a second copy in the core would be a second copy nobody tests. -`board_memory.zig` makes the target the *witness* rather than the gate: `enabled` +`builtins.Board` makes the target the *witness* rather than the gate: `enabled` is keyed on the platform, and a `comptime` block then refuses to compile if that platform is hosted, is not freestanding, or is wasm — because whatever else `esp32p4` means, it has to still be a machine whose addresses are the bus's -(`board_memory.enabled`). +(`builtins.Board.enabled`). = The control filesystem <fs> -== acmefs as a pure transaction +== Namespace and transactions -plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, `body`, -`ctl`, `data`, `event`, `tag`, and a program that opens those files IS an editor -extension — no plugin API, no embedded interpreter, no rebuild. `pardes --fs` -serves the same tree over Linux FUSE (`src/fuse.zig`), and `src/acmefs.zig` is -the whole of what the files MEAN. +`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/<name>` and retain their identity through Save. -A filesystem is a request/response protocol driven by other processes, which is -exactly the kind of concurrency the core does not have and must not grow. acme -answers it with a thread per in-flight request — `xfidallocthread`, a `Channel` -per `Xfid`, a `QLock` per window. pardes cannot and should not, so `acmefs.zig` -is a pure main-thread transaction, `handle(p, req) Reply`: no thread, no waiting, -no callback, no allocation on the hot path, freestanding-safe, and unit-testable -with no FUSE anywhere near it. +Every native session opens a 9P2000 Unix socket. The wire root exposes `os` and +`self`, without the editor's `/n` prefix. `self/pane/<serial>` 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. -Requests arrive as an ordinary `Event.fs_req` and answers leave as an ordinary -`Effect.fs_reply`, so the transport is the queue every other host/core message -already uses. A backend with no threads at all is not a special case: it either -never sends a request, or sends one from its own frame loop. And acme's blocking -`event` read — which waits for the user to do something, parking the `Xfid` in -`w->eventx` until a later `winevent` sends it a message — is `Status.again` here: -"nothing consumed, ask me again". The waiting lives in the host, which is where -the kernel's request already is. The core keeps no waiter list and no wakeups. +`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. -One divergence from acme is deliberate: acme counts RUNES, pardes counts BYTES, -clamped to grapheme boundaries. Every offset in this filesystem — `addr`, `data`, -the event records' q0/q1, `index`'s lengths — is a byte offset, because pardes is -byte-addressed end to end (selections, look spots, LSP offsets) and a second -coordinate system would mean an O(n) conversion at every boundary and a lossy -`addr=dot`. acme pays that cost the other way round: it keeps the document as -`Rune*` and converts on every utf read, in a function that carries a -"BUG: stupid code: scan from beginning" comment for its cache miss. The two agree -for ASCII, which is what scripts compute with. +== Nested Look -`Pardes.fs` is `acmefs.State`, zero-initialised and inert: a core nobody scripts -pays one branch per edit and nothing else. +Pane shells inherit `PARDES_9P`, `PARDES_PANE` (the pane serial), and +`PARDES_FORWARD_LOOK`. A child launch resolves its OS-relative argument in +the child's working directory, then writes `look <path>` to the parent's +`self/pane/<serial>/ctl`. Explicit `/virtual` and `/n` paths resolve in the +parent. The ordinary filesystem update performs layout and drains host effects. -== The nested socket +`--nested` starts a separate editor and disables forwarding from its direct +pane shells. Its 9P socket remains available for control and plugins. There is +no executable-name discovery or separate Look listener. -`src/nested.zig` listens on `<dir>/pardes-<pid>.sock`, where `<dir>` is -`$XDG_RUNTIME_DIR` — a per-user 0700 tmpfs the login session already cleans up — -or `~/.local/state/pardes` when the session has none. It accepts exactly one -verb, `Look <path>[:<line>]`, and nothing else, which is the security property. -That is how a `pardes <file>` run inside a pardes hands the file to the outer -session instead of stacking a second full-screen UI inside one of its panes. - -It arrives as an `Event.command` rather than a direct `executeBuiltinLine` call -so that it gets the trailing sync and the ordinary effect drain: `Look` on a -directory emits a `.spawn` the shell has to perform. - -The detached sockets live in the same per-user directory under a different name, -`pardes-detached-<name>.sock`, and both sides derive the path from one predicate -in one file so that the side which binds and the side which connects cannot -disagree (`server.socketPath` and `server.sessionPath`). A frontend that finds a socket at -a derivable path which is not a private one of ours refuses with `NotPrivate` -rather than reporting "no session": a planted socket collects every keystroke -typed into the frontend that trusts it, and the two cases need different answers -from a human. +Detached frontends use `pardes-detached-<name>.sock` in the same runtime +directory. The shared Unix socket conventions live in `src/9p_io.zig`. = Build @@ -1694,7 +1566,7 @@ is the only thing two of them are allowed to disagree about. Line numbers are out(2.12, [`pardes-gui`], [`-Dplatform=gui` --- SDL3, a FreeType atlas, SPIR-V]) out(1.72, [`pardes.wasm`], [`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>`, plus a vanilla DOM shell]) out(1.32, [`libpardes.a`], [`-Dplatform=macos`, then the `macos-app` step #sym.arrow.r `pardes.app` (Darwin host)]) - out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4` (target forced, `:171`); `-Desp32p4-firmware` adds the image and the board steps]) + out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4`; the sibling `05-zig-p4` toolchain builds the firmware image]) row(0.46, text(5.6pt)[a bare `zig build` emits the FIRST TWO together, because those two are what installing pardes means; every other spelling is one invocation each]) row(0.18, text(5.6pt)[beside them, from the same graph: `pardes-isolate` (tty only --- the filesystem is not compiled in) and `hxdiff`'s headless core]) @@ -1758,7 +1630,8 @@ Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the web and the board, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), zig-tree-sitter with 29 grammars for 28 languages (markdown takes two, block and -inline), and `zig_p4` — all pinned through `zig fetch` and wired in `build.zig`. +inline) — all pinned through `zig fetch` and wired in `build.zig`. The board's +`05-zig-p4` firmware toolchain is a separate sibling checkout. Generated during the build: `highlights.scm` into an options module; the vendored helix and zed theme sources into the generated half of the theme ring; @@ -1932,9 +1805,9 @@ over CDP with real DOM pointer and touch events and diff `test/web-snapshots/`; `image-harness` and `pdf-harness` snapshot native PIXEL output through kitty graphics and SDL; `macos-e2e` is an offscreen AppKit snapshot suite over its own seven scripts (`test/macos-snapshots/`: boot, cwd, drop, font, keys, rotate, -trackpad). `esp32p4-test` runs an on-die suite on real hardware, and -`esp32p4-image-size` and `esp32p4-image-check` answer the flash-budget question -with no board attached. +trackpad). In the sibling `05-zig-p4` toolchain, `zig build selftest` flashes and +runs `src/esp32p4/selftest.zig` on real hardware. The local freestanding object +and the standalone GPIO 9P image can both be compiled without a board attached. Two suites are differential rather than golden: `hxdiff` compares the core's motion and operator results against helix case by case, and `hxparity` compares @@ -1970,10 +1843,6 @@ plugin system, because the control filesystem is the extension point (@fs) and needs no API of its own. No async runtime in the core — the shells may thread, the core is single-threaded by construction. -No 9P. The library boundary is exactly where acme put the file server, and the -FUSE mount is already that server with a Linux transport instead of a 9P one; a -sixth shell could serve `Surface` and `Event` over 9P without touching the core. - "No config files" held until the startup file (`docs/config.md`) arrived, and that is the narrowest thing the phrase could still cover: a list of builtin COMMANDS run before the first frame — no schema, no new vocabulary, and no key remapping. @@ -2080,14 +1949,11 @@ selection, the document outline into `+PdfSections`, fit-width/fit-height and a themed duotone tint — the last three named in the pane's own live tag. Tutor: embedded text as file pane. -*Language.* ZLS compiled in and called IN-PROCESS — no subprocess, no -JSON-RPC, no daemon and nothing cached between queries — behind a -one-function seam (`lsp.query`) so that swapping a backend touches nothing -else. `.zig` only, and on demand only: helix's five `g` gotos, ten builtins -under `SPC l`, `]d`/`[d`, `=`, and insert-mode Tab after a `.`. Answers become -rows in `+Search`, `+Hover` or `+Lsp` — output buffers of the same kind `/`, -Find and Help fill — or, for a rename, byte ranges the core applies in one undo. The -web shell has no threads and therefore no backend at all. +*Language.* The native host snapshots each request and runs it outside the UI +loop. Zig uses the in-process ZLS backend; configured external language servers +use the JSON-RPC client. Results become output rows or edits, applied only while +the request's pane and revision still match. Web and board builds have no +language backend. See `docs/lsp.md` for configuration and commands. *Chrome.* One theme per `.zig` file, folded into the ring at comptime: ours first — helix (default; near-black page, chrome one grey-ramp step off it, @@ -2119,14 +1985,14 @@ starting point without adding inheritance or a second theme vocabulary. Colors toggles all recolor passes; Debug stats overlay; Dump writes state ZON. Eleven panel transitions and three scene bits are settings, one builtin each, generated from -`runtime_config.settings`. +`config.Runtime.settings`. *Session.* `--detach[=name]` runs a core with no terminal; `--attach[=name]` makes a thin frontend over a unix socket; `Attach [name]` (`SPC s a`) hands a running frontend's screen to a detached core, connecting before it swaps; `Detach` (`SPC s D`) leaves a session that carries on. Up to 32 frontends on one session, all showing the same screen; the pane shells belong to the daemon and -outlive every frontend. `--fs` serves the acme control tree over FUSE; a nested +outlive every frontend. The default 9P socket serves the control tree; a nested `pardes <file>` 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. |
