summaryrefslogtreecommitdiff
path: root/docs/design.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design.typ')
-rw-r--r--docs/design.typ406
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.