diff options
Diffstat (limited to 'docs/design.typ')
| -rw-r--r-- | docs/design.typ | 284 |
1 files changed, 203 insertions, 81 deletions
diff --git a/docs/design.typ b/docs/design.typ index 2492329d..ff013f49 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -12,16 +12,17 @@ = What it is Pardes is a text environment in the acme tradition: columns of panes, each pane a -tag line plus a body; the body is a live terminal, a file, or an image. 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. +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. -It runs in three places: the terminal (libvaxis), a native SDL3 window (the -steamdeck), and the browser (a freestanding wasm core driven by vanilla -JavaScript and rendered as HTML/CSS). The rewrite exists because +It runs in four places: the terminal (libvaxis), a native SDL3 window (the +steamdeck), the browser (a freestanding wasm core driven by vanilla +JavaScript and rendered as HTML/CSS), and a native macOS app (an AppKit and +CoreText shell over a static `libpardes.a`). The rewrite exists because the prototype grew three parallel implementations of one program. The rewrite has -exactly one program and three thin shells. +exactly one program and four thin shells. = The shape @@ -33,9 +34,18 @@ its own renderer; the core owns everything the user would recognize as pardes. columns: (auto, 1fr), stroke: 0.4pt, [*core*], [layout, panes, modes, selection, click semantics, themes, text of the - UI. Pure state machine: `update(event)` mutates, `surface()` describes. No - syscalls, no rendering, no event loop.], - [*shell*], [one file per platform. Owns the event loop, translates native input + 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.], + [*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and + gamepad files, a C font loader and twelve GLSL shaders; web and macOS each + add a host language). + 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).], ) @@ -45,34 +55,48 @@ build today is a separate 800-line read-only replay viewer. In the rewrite, loading a dump of another instance is a first-class feature of the one application, the way acme handles dumps: `pardes -l state.zon` on every platform reconstructs the panes (terminals by replaying their raw VT streams -into fresh emulators, files and images from their bytes). The web shell embeds +into fresh emulators, files, images and PDFs from their bytes — a PDF rides the +dump's `image` kind, carrying its path, its bytes and the page it was on). The web shell embeds a dump and leaves `spawn` unanswered. Same core, no viewer fork. The boundary is two data types, both plain values: -*Event* (in): `key`, `text`, `mouse` (press/release/motion; button left, middle, -right; cell position), `wheel`, `pinch`, `resize`, and `output` (bytes a pty -produced, tagged with the pane id). Touch policy belongs to the shell: web maps +*Event* (in): `key` (which carries typed `text`), `mouse` (press/release/motion/ +drag; button left, middle, right and the four wheel directions; cell position; +and the `ctrl` flag, because Ctrl-left-click is goto-definition), `pinch`, +`touch_scroll`, `resize` (cols, rows, and — in a MuPDF build — the cell's pixel +size), `paste`, `pdf_scroll`, +`output` (bytes a pty produced, tagged with the pane id), `eof`, `command` (a +builtin line arriving from another process over the nested socket), `tick`, and +the answers to an ask: `lsp_resp`, `pipe_resp`, `file_changed`. 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, three dialects translated at the door. +input: one event vocabulary, four dialects translated at the door. -*Surface* (out): a grid of cells — grapheme, fg, bg, attrs — plus a cursor and -per-pane rectangles. This is the *canonical interface*: it is literally what the +*Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a +cursor, and the pixel attachments. 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 an attachment per pane: the SDL -shells blit RGBA, the tty shell falls back to the petscii matcher, kitty -graphics when available.) +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 falls +back to the petscii matcher, kitty +graphics when available. The per-pane rectangles live on `Pardes`, not on the +surface: a shell that wants them asks the core.) *Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`, -`resize_pty{pane, cols, rows}`, `open_link{url}`, `read_file{path}`, -`new_file{pane, serial}`, `set_clipboard`, `read_clipboard`, `bell`, `quit`. -The core never performs IO; it asks — and `read_clipboard` is the one ask with -an answer coming back, an ordinary `paste` event, or nothing at all when the -shell cannot read the clipboard (most terminals refuse the OSC 52 read). This +`resize_pty{pane, cols, rows}`, `open_link`, `new_file{pane, serial}`, +`save_file{pane}`, `write_dump`, `set_clipboard`, `read_clipboard`, `lsp`, +`pipe`, `watch`, `quit`. +The core never performs IO for any of these; it asks — and four of the asks have an +answer coming back: `read_clipboard` returns an ordinary `paste` event (or +nothing at all when the shell cannot read the clipboard — most terminals refuse +the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and +`watch` returns `file_changed` whenever the shell notices the file moved under +it. This is what makes the browser build honest instead of a fork: a wasm shell simply answers `spawn` differently (or not at all) — the core does not know. @@ -81,7 +105,7 @@ Platform divergence inside the core is a comptime tag, used the way the stdlib uses `os.tag`: ```zig -pub const Platform = enum { tty, gui, web }; +pub const Platform = enum { tty, gui, web, macos }; // in look.zig: if (platform == .web and target.kind == .url) return .{ .open_link = target.text }; @@ -94,32 +118,43 @@ divergence is visible in one screenful. = Data structures -The whole state is one struct, sized at init, no hidden allocation after: +The whole state is one struct, fixed-size where it can be. 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 - cols: []Col // col: weight + pane ids, top to bottom - panes: []Pane // slot array; id = index - active: Pane.Id - drag: Drag // none | select | move | border | scrollbar - theme: usize // index into themes (228 of them) + ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays + panes: [16]?*Pane // slot array; id = index + active: usize + drag: Drag // none | select | move | border_v | border_h | tag + theme_idx: usize // index into themes (228 of them) colors_on: bool - surface: Surface // rebuilt by surface(), arena-backed - effects: Fifo(Effect) + rects: [16]Rect // where each pane landed, this frame + effects: [4096]Effect + head/len // a fixed ring Pane - tag: TagLine // live prefix (mode, cwd/path) + editable tail - kind: union { term: Term, file: File, image: Image } + tag: TagLine // live prefix (cwd/path) + editable tail + kind: enum { terminal, file, image, pdf } // + independent optional payloads + 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 / image: ?Image / pdf: ?PdfView vweight: f32 mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty + // `v` adds a fourth thing to DISPLAY, "select", + // which is a bool on top of normal, not a mode cursor: absolute body position // rides the scrollback, not the screen - sel: [3]Sel + line/char modal sels // per-button block sels, msel, vsel - edits: Splice // typed runs anchored to absolute rows - undo: edit snapshots // unified undo across term edits and file content + sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and + // char modal ones, and up to 64 cursors + ovl: ?Ovl // ONE typed run, anchored to an absolute row + undo: two stacks, not one — ed_undo[64] of overlay snapshots for a terminal, + // and File.undo of content snapshots for a file -Term = ghostty-vt Terminal + its stream (bytes in via Event.output) File = path + bytes + line index + Syn (tree-sitter highlight bytes) Image = decoded RGBA + petscii grid cache +PdfView = MuPDF document + per-page rasters + cached outline ``` Layout is arithmetic, not objects: columns are weights over the width, panes are @@ -133,21 +168,43 @@ one pane may not move panes it does not touch. 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 metric is lines of code; the number -only goes down. As built, against the prototype's 12,072 lines of Zig: +allocation at init, arenas per frame. + +The metric was lines of code, and for the rewrite itself it held: 7,626 lines +replaced the prototype's 12,072 with three backends instead of one and a half. +It has not held since, and the table below is the honest version rather than +the flattering one. Everything after the rewrite — PDF, a language backend, a +fourth shell, 228 themes, multiple cursors — was added, not traded for +something removed: #table( - columns: (auto, auto, 1fr), + columns: (auto, auto, auto, 1fr), stroke: 0.4pt, - [*module*], [*lines*], [], - [core (pardes.zig + look.zig + syntax + image + dump)], [4,289], [all semantics, all platforms], - [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [carried over, unchanged], - [shells (tty.zig; gui.zig = SDL native; web.zig + web/ = browser)], [2,768], [translate + render only], - [main.zig + build.zig], [484], [three targets, codegen for queries], - [*application total*], [*7,626*], [vs 12,072 — three full backends instead of one and a half], - [snapshot harness (snapshot.zig + e2e_harness.zig)], [831], [the parity oracle, kept], + [*module*], [*at the rewrite*], [*now*], [], + [core and the pieces that grew out of it — 25 files: pardes, look, syntax, image, dump, builtins, config, output/file/term panes, normal\_input, nested, fonts, themes, …], [4,289], [22,220], [all semantics, all platforms], + [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [2,569], [carried over], + [pdf.zig], [—], [1,581], [MuPDF, `-Dmupdf`], + [lsp/ (seam + in-process ZLS backend)], [—], [1,663], [`.zig` only], + [shells: tty 1,396; gui 4,968; web 601; macos.zig 1,579], [2,768], [8,544], [translate + render only], + [main.zig + build.zig], [484], [1,471], [four targets, four codegen passes], + [*application total* — every `.zig` under `src/`, plus `build.zig`], [*7,626*], [*38,048*], [vs the prototype's 12,072], ) +Those six rows are a partition: no file is in two of them and none is left out, +which the old table could not say (its rows summed to 1,325 more than its own +total, and twenty-six files were in no row at all). What the total deliberately +does NOT count, because none of it is Zig under `src/`: 2,250 lines of Swift for +the macOS app and its icon generator, 1,541 lines of C bridging MuPDF, 549 of +JavaScript, HTML and CSS for the web shell, 216 of C for the SDL font loader, +`mupdf.zig` (355) at the root, and `tools/` + `build/` (896). The test tree is +another 5,234 lines of Zig across eight harnesses, plus 1,028 of Swift and 610 +of JavaScript — against 831 at the rewrite. + +Two things that number is not. It is not one program's worth of growth — the +macOS and web shells and the PDF and language work are four 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. + = Testing: the old program is the oracle Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the @@ -156,8 +213,9 @@ 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 (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts cover the -checklist in Appendix A; all pass. Determinism pins: fixed workdir paths (they +byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the +checklist in Appendix A at the rewrite; eighty-seven cover it and everything +since. 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. @@ -169,36 +227,66 @@ text fields are the contract); and typed insert runs don't survive a dump replay (they are an overlay, not pty bytes — the prototype's replay viewer had the same semantics). -Shell correctness (that SDL draws the grid it was given, that vaxis diffs -correctly) is out of snapshot scope and covered by each shell's single smoke -test. +Shell correctness is no longer out of scope, and that is the biggest change to +this section since the rewrite. `web-snap` / `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. 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). = Build -`zig build` produces three statically linked artifacts: `pardes` (vaxis), -`pardes-gui` (SDL3), `pardes.wasm` + a vanilla DOM shell (wasm32-freestanding). -Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL -(castholm, lazy), FreeType, and zig-tree-sitter + grammars — all pinned through -`zig fetch` and wired in `build.zig`. Codegen during build, consumed by comptime: -`highlights.scm` → options module; tutor text → embedded pane content. Debug -builds are incremental for the seconds-loop; release builds are the product. +Every `zig build` builds ONE platform: `platform` is an option defaulting to +`.tty`, so a bare `zig build` gives `pardes` (vaxis) and the other three are +separate invocations — `-Dplatform=gui` for `pardes-gui` (SDL3), +`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` for +`pardes.wasm` plus a vanilla DOM shell (any other spelling of that one fails on +purpose rather than building something silently wrong), and `-Dplatform=macos` +plus the `macos-app` step for `pardes.app` wrapped around a static +`libpardes.a` on a Darwin host. Native dependencies are ghostty, vaxis, uucode (shared config), +zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default +everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and +zig-tree-sitter + 26 grammars — all pinned +through `zig fetch` and wired in `build.zig`. Generated during the build: +`highlights.scm` → an options module; the vendored helix/zed theme +sources → the generated half of the theme ring; the tracked `.zig` sources → +the web shell's read-only archive; eight GLSL shaders → SPIR-V. 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. = What is deliberately absent No render abstraction over the shells (the surface *is* the abstraction). No -config files. No plugin system. No async runtime in the core — the shells may +plugin system. No async runtime in the core — the shells may thread, the core is single-threaded by construction. No 9P yet — but the -library boundary is exactly where acme put the file server, and a fourth shell -could serve `Surface` and `Event` over 9P without touching the core. +library boundary is exactly where acme put the file server, and a fifth shell +could serve `Surface` and `Event` over 9P without touching the core. Half of +that is already built and unnamed: `src/nested.zig` listens on a unix socket at +`$XDG_RUNTIME_DIR/pardes-<pid>.sock` (`~/.local/state/pardes` without one), +accepting exactly one verb — `Look <path>`, and nothing else, which is the +security property — and 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. + +"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. = Appendix A: feature parity checklist From the prototype survey; every line is covered by at least one of the -eighteen event scripts in `test/snapshots/` (boot, tty, edit, scroll, modal, -look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, ttyonly, -syntax, fileedit, images). +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 sixty-nine more added since for +everything below that the prototype never had. -*Layout.* Columns by weight (≤6), panes by vweight (≤16); global topbar; +*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); dying pane's weight absorbed by one sibling; emptied column hands width to a neighbor; border-drag resize on a @@ -223,13 +311,29 @@ 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. Middle+left chord: kept +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. Wheel: scroll hovered pane, batched. -*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^ gg ge gh gl G`, -`Ctrl-d/u/f/b`, `zt zz zb`), insert entries (`i a I A o O`), `v`/`x` -selections, `d c y p u U`, Enter=look Tab=execute at cursor. insert: +*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `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 ]<space>`), `/` 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, @@ -252,21 +356,39 @@ Colors and Crt left it for their leader paths. OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the nushell/helix regression), greeting `ls`, auto-follow output unless navigating. File: line-number gutter (fixed width), tree-sitter highlights -(c/cpp/zig minimal tier; 25 grammars full tier; re-highlight on edit, +(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). Tutor: embedded text as file pane. +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. +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. *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, acme-light — then +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. helix and dark leave -a child's ANSI palette native; acme-light and the zed exports resolve it onto +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 and the move box; the selection colors stay fixed -because they encode which button you pressed. `NextColor` browses the ring one +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 <name>` jumps to one and `ThemeSel` (`SPC t t`) lists them all into an output buffer whose rows are those very commands — execute a row (Tab, middle |
