summaryrefslogtreecommitdiff
path: root/docs/design.typ
blob: 382063cdfd69373a5c0a3cd12c0029b1b0c194e6 (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
#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 (wasm + SDL3 + emscripten). 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). A touch tap is a left press; 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. The SDL shells rasterize the same grid
through a glyph atlas. 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}`,
`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:     u2             // index into themes
  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)], [2,768], [translate + rasterize 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` + shell page (emscripten). Same 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; `--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 pane cwd
(`/proc/pid/cwd`) or file's dir; 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),
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 from system clipboard (OSC 52 request/reply); yank
mirrors out via OSC 52.

*Tag.* Live prefix (mode indicator, cwd or path) + editable tail with the
full modal editor; defaults `Del` / `Save Del`; image tags are toggle words
(`Petscii C64|Term Ascii`). Topbar: `Kill Newcol Tutor Debug Colors NextColor
Dump` — execute-only (left click inert).

*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.* Themes dark + acme-light (terminal-native vs paletted); 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, pinch + touch (two-finger = middle), render
scale, link-look opens a new tab (new — the prototype has no web link
handling). SDL shells: stb_truetype atlas over the SDL GPU API (SPIR-V on
native, GLES3 on web), letterboxed web scaling, two-finger drag = wheel, tap
= middle click, pinch, touch debug overlay, `PARDES_TEST` PPM capture and
`PARDES_TEST_GRID` text-grid capture. Joystick cursor for the steamdeck is
*new* scope — the prototype ships no gamepad input.