summaryrefslogtreecommitdiff
path: root/docs/design.typ
blob: c6f0e73e47360ee92db515895f9b9576d5a698f4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
#set page(margin: 2.2cm)
#set text(font: "New Computer Modern", size: 10.5pt)
#set par(justify: true)
#show heading: set block(above: 1.4em, below: 0.8em)

#align(center)[
  #text(17pt)[*Pardes: A Text Environment*]

  #text(11pt)[design for the rewrite — 2026-07-05]
]

= 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.

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
the prototype grew three parallel implementations of one program. The rewrite has
exactly one program and three thin shells.

= The shape

Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and
events, and you read state out of it. Every platform owns its own event loop and
its own renderer; the core owns everything the user would recognize as pardes.

#table(
  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
    into core events, renders the core's surface, and performs the core's
    requested effects (spawn a shell, write a pty, open a link).],
)

This also collapses the prototype's *two* applications into one: the browser
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
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
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.

*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
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.)

*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`, `bell`, `quit`. The core never
performs IO; it asks. 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.

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 };
// in look.zig:
if (platform == .web and target.kind == .url)
    return .{ .open_link = target.text };
```

There is one such file by design: `look.zig` holds *what a click on text means* —
expansion of the word under the click (acme's `isfilec`), path/`:line`
resolution, url detection, and the per-platform outcomes, kept adjacent so the
divergence is visible in one screenful.

= Data structures

The whole state is one struct, sized at init, no hidden allocation after:

```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)
  colors_on: bool
  surface:   Surface        // rebuilt by surface(), arena-backed
  effects:   Fifo(Effect)

Pane
  tag:   TagLine            // live prefix (mode, cwd/path) + editable tail
  kind:  union { term: Term, file: File, image: Image }
  vweight: f32
  mode:  enum { normal, insert, tty }  // helix-modal; tty = raw to the pty
  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

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
```

Layout is arithmetic, not objects: columns are weights over the width, panes are
weights over the column. `splitBelow` shrinks only the source pane;
`absorbVWeight` gives a dying pane's weight to one sibling; an emptied column
hands its width to a neighbor. Minimal motion is the invariant: an operation on
one pane may not move panes it does not touch.

= Style

TigerStyle, plus house rules proven in the prototype: imperative and flat; one
big `update` dispatch, not handler objects; no one-line helpers — inline the
four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
allocation at init, arenas per frame. The metric is lines of code; the number
only goes down. As built, against the prototype's 12,072 lines of Zig:

#table(
  columns: (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],
)

= Testing: the old program is the oracle

Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the
`zig build snap` step): it forks either binary in a pty, feeds it an *event
script* (one line per input: keys, SGR mouse, resizes, sync points), and
captures the rendered grid — text, cursor, and per-cell style runs — through
its own ghostty terminal. Goldens are generated from the old binary
(`zig build snap -- --update`); the new binary must reproduce them byte for
byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts cover the
checklist in Appendix A; 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.

Three deliberate deviations surfaced by the oracle, kept after review: the
greeting `ls` waits for the shell's first output (the prototype raced bash's
startup and won only by allocator luck); dump files compare with base64 pty
history elided (it encodes prompt-redraw micro-timing, not state — the cleaned
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.

= Build

`zig build` produces three statically linked artifacts: `pardes` (vaxis),
`pardes-gui` (SDL3), `pardes.wasm` + a vanilla DOM shell (wasm32-freestanding).
Same native dependency set as the prototype — ghostty, vaxis, uucode (shared config), zstbi, SDL
(castholm, lazy), zig-tree-sitter + grammars — all via `zig fetch`, wired in
`build.zig`. Codegen during build, consumed by comptime: tree-sitter
`highlights.scm` → options module; tutor text → embedded pane content. 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
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.

= 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).

*Layout.* Columns by weight (≤6), panes by vweight (≤16); 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
pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints;
move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move
pane to new column; Ctrl-w h/j/k/l directional focus; body-normal Esc toggles
between the latest document and terminal (the same builtin as SPC w t);
`--tty` single-pane mode.

*Mouse.* Left: select (block, stays highlighted after drag, pins cursor,
enters normal), click clears, tag-row click enters tag edit, scrollbar
click scrolls up-to-row (right button: down), border/move drags. Middle:
execute — no-drag expands to file-ish word (alnum `.-+/:@_~`); builtin or send
to shell; does not focus. Right: look — peel `:NNN`, resolve against the
clicked pane's dir (`/proc/pid/cwd`, or a file's dirname) and, if that fails,
against every other live pane's, most-recently-focused first (the jump stack
backwards, duplicate dirs skipped; absolute words try one dir and stop) so a
relative name is openable from any window that can see it; dir → shell + `ls`
in source column (dedup by cwd), file → file pane at line (dedup by path,
first doc opens left column and may evict a lone pristine shell; later docs
split below the existing doc — an output pane, `+Search`/`+Help`, is not a
doc for either half of that rule: it never claims a column, it splits below
whatever pane asked for it, and nothing splits from it), image ext → image
pane. 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:
click-and-type; terminals get splice runs (shift right, never overwrite;
absolute-row anchored), files get real edits. tty: raw pty forwarding
(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable,
promptClickMove on entry, prompts visible (hidden in the other modes via
OSC 133). `p` pastes the yank register; yank
mirrors out via OSC 52.

*Tag.* Live prefix (mode indicator, cwd or path) + editable tail with the
full modal editor; defaults `New Del` / `Save New Del`; an image tag is
`img <path> New Del` like any other (its renderer toggles are builtins under
`SPC t p/l/a`). Topbar:
`New Newcol Find Grep Help Tutor Dump NextColor Debug Kill` — execute-only
(left click inert);
Colors and Crt left it for their leader paths.

*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics,
OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the
nushell/helix regression), 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,
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.

*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
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
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
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 — so n/N EXECUTE each row
instead of looking it and stepping the list wears the themes. Colors toggles
all recolor passes; Debug stats overlay; Dump writes state ZON.

*Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel,
scrollbar, Colors/NextColor, and link-LOOK opening a new
tab (new — the prototype has no web link handling). One-finger touch is
deliberately complete by itself: tap = LOOK; drag past a small slop = natural
scroll, with no LOOK on release. A build-generated read-only archive lets LOOK
open the current contents of Git-tracked Pardes `.zig` files despite the web
shell having no host filesystem. The published launcher dump is captured from
a running Pardes TTY after `git ls-files '*.zig'`, so its complete terminal
listing is the set the user can open. The freestanding module carries no host
libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into
the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains
tap-LOOK/drag-scroll, but finger-down on
a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture
for its lifetime, keeping layout drags out of the scroll heuristic. Touch
input translation and scroll/tap state live entirely in the JavaScript shell.
The native SDL shell uses an stb_truetype atlas over the SDL GPU API (SPIR-V).
The browser exposes each cell as selectable, inspectable text and applies the
surface styles with CSS. The browser `.snap` harness drives real Chromium touch
input and reads both text and per-cell styles from the DOM renderer's packed
surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships
no gamepad input.