summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 15:58:58 -0300
committerGabriel Schneider <[email protected]>2026-08-11 16:26:20 -0300
commit4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch)
treeface852acae5bc347e6bab2bb5cede501e0ce1d3 /docs
parentdedfdea43f0d6c7151c541284c81027969d89032 (diff)
parent89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff)
downloadpardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz
pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump. No textual conflicts, and two things the merge needed: - nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it `void` (glibc hides it behind a versioned symbol std cannot name), so the tty build stopped at 'type void not a function'. statNoFollow keeps fstatat on darwin and asks statx on linux for the same three fields, which is what this file did before the branch generalized it to both platforms. - .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so. linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig also type-checks for aarch64-macos.
Diffstat (limited to 'docs')
-rw-r--r--docs/config.md6
-rw-r--r--docs/macos.md687
2 files changed, 630 insertions, 63 deletions
diff --git a/docs/config.md b/docs/config.md
index cba7e5fc..7b35586c 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -8,6 +8,12 @@ Native pardes builds read a per-user `pardes` file before the first frame:
- Windows: `%LOCALAPPDATA%\pardes`, with `%USERPROFILE%\AppData\Local\pardes`
as the fallback.
+`Config` (`SPC f c`, or the word executed anywhere) prints the resolved path
+into a `+Config` output pane, so the machine answers this rather than the list
+above. The path is printed whether or not a file is there — that is the case
+you ask in — and the row is ordinary text, so a right click on it opens the
+file.
+
The browser build has no local user-config path and does not load this file.
The format is one existing builtin command per line, using the same spelling
diff --git a/docs/macos.md b/docs/macos.md
index e430f677..7f695000 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -85,14 +85,20 @@ vocabulary: three buttons, where 1 selects, 2 executes and 3 looks, with wheel
directions as ordinary buttons rather than a separate axis. Ctrl is the only
modifier the core consults (a left press with ctrl is goto-definition), but the
full mask is passed anyway to keep the signature identical to the web one.
-`pardes_scroll` carries a fractional row delta from a trackpad; the Zig side
-accumulates it and synthesizes whole `wheel_up`/`wheel_down` presses, because
-the core scrolls on button events and its `touch_scroll` event only records the
-residual for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and
+`pardes_scroll` carries fractional row and column deltas from a trackpad; the
+Zig side accumulates each axis separately and synthesizes whole
+`wheel_up`/`wheel_down`/`wheel_left`/`wheel_right` presses, because the core
+scrolls on button events and its `touch_scroll` event only records the residual
+for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and
`src/web/app.mjs` both do exactly this already. A real mouse notch skips the
-smoothing and goes through `pardes_mouse`. `pardes_resize` also carries one cell
-in *physical* pixels, which only the native PDF placement path reads — pass the
-backing-store size, not points.
+smoothing and goes through `pardes_mouse`. `pardes_rotate` is the same shape for
+a two-finger twist, spent as `n`/`N` — see the trackpad section.
+`pardes_command` runs one builtin command line through the core's own `command`
+event, which is the channel a nested pardes speaks; here it is what a menu item
+is made of and what opens a path from argv or the Dock (`Look <path>`).
+`pardes_take_haptic` reports the Look or Exec the core just performed and clears
+it. `pardes_resize` also carries one cell in *physical* pixels, which only the
+native PDF placement path reads — pass the backing-store size, not points.
**Runtime callbacks.** `pardes_runtime_s` is a `userdata` and two functions,
copied by value during init so the struct need not outlive the call. `wakeup`
@@ -126,6 +132,402 @@ ABI.** Everything else is main-thread only, and the host's `wakeup` must do
nothing but hop — one `DispatchQueue.main.async` that calls `pardes_tick` and
marks the view dirty.
+## The trackpad is the third button
+
+acme wants three mouse buttons — 1 selects, 2 executes, 3 looks — and the
+machine this runs on has a glass rectangle. So the rectangle is taught to speak
+the vocabulary, and the mapping is the one macOS itself already suggests:
+
+| gesture | button | verb |
+| --- | --- | --- |
+| one finger | 1 | select |
+| two fingers | 3 | Look |
+| three fingers | 2 | Exec |
+| a deep press | 2 | Exec |
+| two fingers twisted | — | `n` / `N` |
+
+**The finger count decides, not the button stream.** This is the part that only
+real hardware could teach, and it is worth spelling out because the obvious
+implementation is wrong. macOS's secondary click is "click or tap with **two or
+more** fingers", so with that setting on — the default — a *three*-finger click
+is delivered as `rightMouseDown` exactly like a two-finger one. A view that
+trusts the stream cannot tell them apart and quietly does Look for both. The
+trace that caught it, from a real trackpad:
+
+```
+pardes: rightMouseDown: resting=2
+pardes: rightMouseDown: resting=3
+```
+
+So all three button streams funnel into one `beginClick`, which resolves the
+button from the fingers first and falls back to the stream only when there are
+no fingers to count — which is exactly the real-mouse case, where right is Look
+and the middle button is Exec.
+
+The count comes from `event.touches(matching: .touching, in: nil)`, with `nil`
+rather than the view because that argument filters on touch/view association and
+an association that fails does not raise, it returns zero fingers — a
+two-finger Look silently degrading into a select. Belt and braces: the view also
+keeps a running `restingFingers` from the four `touchesXxx` callbacks, because
+the touch set hanging off a *mouse* event is an accident of how the click was
+produced and can come back empty. The mouse event's own set wins when it has
+anything in it.
+
+Whatever it resolves to is then **latched** for the drag and the release: the
+core tracks a drag keyed by button, and answering a press of 3 with a release of
+1 strands it holding a sweep nothing will ever end.
+
+A deep press arrives as `pressureChange` reaching stage 2, and only the
+transition counts — AppKit repeats stage 2 for as long as the finger stays down.
+By then a press has already gone out, so it is *released* before the middle one
+is sent. That ordering is not tidiness: a middle press arriving while the core
+holds a left select-drag is acme's 1-2 chord, which is **Cut**. The release
+costs a cursor move at the click point, which is what clicking there would have
+done anyway.
+
+Which press gets upgraded is deliberately not restricted to the left one, and
+that too came from the trace: on a Force Touch trackpad the deep press usually
+rides a click that already went out on the *right* stream, so gating on a
+latched left button meant the conversion never fired at all — the log showed
+`pressure: stage=2 latched=nil` and nothing else. Any in-flight click upgrades;
+already-Exec is the only case with nothing to do. The view also needs
+`NSPressureConfiguration(pressureBehavior: .primaryDeepClick)` or stage 2 is the
+system's business and never arrives — and the user needs "Force Click and
+haptic feedback" on in System Settings, which nothing in this process can read.
+
+Twisting two fingers is a dial, and a dial over a list of search hits is `n`.
+`pardes_rotate` takes raw degrees and libpardes quantizes them, one search step
+per 10°, keeping the remainder — the same accumulate-and-spend shape as
+`pardes_scroll`, in Zig for the same reason: it is then unit-tested on a machine
+with no trackpad. AppKit reports counterclockwise as positive and the forward
+step is clockwise, so the sign inverts here and nowhere else. The banked
+remainder is deliberate hysteresis; a gesture beginning passes 0 to clear it, so
+the first degree of a new twist cannot inherit a nearly-complete notch from the
+last one. Measured against a real twist: 95 events, mean 3.3° each — 20° was
+more than a wrist gives without thinking about it and made the dial feel stuck,
+where 10° is still a deliberate turn and 36 steps to a revolution.
+
+### Momentum
+
+`pardes_rotate_end` says the fingers came off, and how fast they were moving
+when they did decides everything. AppKit gives rotation no momentum phase of
+its own — `momentumPhase` belongs to scroll — so the release speed is measured
+here, off the monotonic clock, as a smoothed degrees-per-second over the event
+stream.
+
+The curve is the point. Momentum ramps up **from zero** at a 70°/s floor rather
+than switching on at it:
+
+```zig
+excess = min(|speed| - rotation_fling_floor, rotation_fling_max)
+```
+
+A plain threshold would hand out two free notches the instant it was crossed,
+and the same gesture a hair quicker jumping twice as far is how a control stops
+feeling like a control. So a slow, deliberate turn coasts not a little but not
+at all, and the harder it is thrown the further it goes — bounded, by the cap,
+at about eleven matches for the hardest flick a trackpad can report.
+
+Two things stop a fling that was never thrown. A release more than 90 ms after
+the last motion event is a hand that *stopped* and then lifted, which is the
+most deliberate twist there is and the one a stale velocity sample would fling
+hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in
+progress, the way a hand catches a dial.
+
+The coast itself is spent by `pardes_tick`, one fixed 1/60 step per tick with a
+0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so
+it rides the same 16 ms re-pump a theme transition does and needs no clock of
+its own. Fixed rather than measured on purpose: one fling then spends the same
+travel every time, which is what lets `rotate.snap` assert it instead of
+asserting the machine's timer jitter.
+
+Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s,
+under the floor) and asserts the screen is byte-identical across the release,
+then at 12° per 5 ms and asserts it is not. The list it walks is twenty-four
+hits rather than three for a reason worth keeping: `n` at the last match has
+nowhere to go, so on a short list the hardest possible flick and no flick at
+all produce the same screen — a golden that would have passed before momentum
+existed.
+
+## A drop is a click plus Look
+
+Files dragged onto the grid open beside the pane they were dropped on.
+
+Finder and the Dock already reached the app through `application(_:open:)`, but
+that path cannot say *where* — it opens next to whichever pane happened to have
+focus. A drop knows where the hand was, and in acme that is the whole
+difference, because `Look` places the document relative to the pane it runs in.
+
+So the definition is exactly two things the hand could have done itself: the
+pointer's cell gets a left press and release, focusing that pane the way a
+click there would, and then the ordinary `Look` builtin runs in it. Drop on a
+tag and you clicked a tag. **No drop concept was added to the core**, and there
+is no case here to special-case.
+
+`performDragOperation` decodes the pasteboard and the location and then calls
+`drop(_:at:)`, one call below the event, for the reason every gesture in this
+file is split that way: `NSDraggingInfo` is a protocol with a dozen members and
+no public conformer, so a test that had to build one would be testing its own
+stub. `test/macos-snapshots/drop.snap` drives that entry point and asserts the
+placement rather than the opening — the second file is dropped *inside the pane
+the first one opened* and has to land beside it, which is only true if the
+click went where the pointer was.
+
+## The titlebar follows the focused pane
+
+`pardes_active_path` and `pardes_active_dirty` are read once per pump into
+`representedURL`, `title`, and `isDocumentEdited` — the proxy icon you can drag
+and Cmd-click for the path, the filename, and the dot in the close button.
+
+There is no document architecture behind this and deliberately so: no
+`NSDocument`, no save panel, no "do you want to save" on close. Save is a
+builtin, the pane's tag already says so, and this is the same two facts spelled
+where a Mac user looks for them. A terminal or an output buffer is not a
+document, so focus landing on one clears the icon and puts the title back to
+`pardes` rather than leaving a stale file there. PDFs and images do get an
+icon: they are real paths, and a proxy icon is about the file, not about who
+may edit it.
+
+The dirty half needed the one core change in all of this. `File` counted
+`revision` but never recorded which edit was last *written*, so no shell could
+derive "unsaved" — `File.saved_revision` is that watermark, set by `Save` at
+the moment the write is asked for. Nothing in the core renders it and no other
+shell reads it; it exists because a windowed host has somewhere to put the
+answer. It is marked at ask-time rather than on completion because `save_file`
+carries none back, which makes it exactly as honest as the tagline already was.
+
+## When a gesture looks like it did nothing
+
+All three verbs are cheap to mistake for broken, because acme's verbs are about
+the *word under the pointer* and most words resolve to nothing:
+
+- **Look** (two fingers) on a filename opens it; on a word that names no file
+ and matches nothing else on screen, it searches, finds where it already is,
+ and the screen does not move. The pulse still fires — the gesture worked.
+- **Exec** (three fingers) on a builtin name runs it. On ordinary prose it types
+ that word at a shell, which needs a terminal pane to type into.
+- **`n`/`N`** (twist) steps a results buffer. With no `/pattern`, Grep or Find
+ behind it there is nothing to step.
+
+`PARDES_LOG=1` prints every decoded gesture to stderr — the fingers counted, the
+stream it came in on, the button it resolved to, the pressure stage, the
+rotation degrees. It exists because which events a trackpad produces is decided
+by hardware plus four System Settings switches this process cannot read, and
+because guessing at that from a screenshot cost an afternoon.
+
+## Haptics
+
+Every Exec and every Look taps the trackpad. The core arms a one-slot pulse in
+the ONE dispatcher — `lookAt` and `execute`, the two functions a middle click, a
+right click, Enter, Tab, a tag chord, `n`/`N` stepping and the `Look`/`Exec`
+builtins all funnel into — and the host takes it once per pump with
+`pardes_take_haptic`. Exec gets `.generic`, the definite tap of something done;
+Look gets `.alignment`, the lighter detent AppKit uses when a dragged guide
+snaps. A pulse, not a queue: five Execs inside one keystroke are one thing the
+hand did.
+
+Three details are load-bearing. `execute` arms only at `exec_depth == 0`,
+because `Exec ls` re-enters as `ls` and one Tab is one gesture however many
+words it unwraps to. `init` and `initFromDump` take the pulse and drop it, so a
+config file that opens a file with `Look` does not buzz at boot. And the field
+is `HapticSlot`, `void` on every platform but this one, the way `PdfSlot` is
+`void` without MuPDF — no other shell reads it, so no other shell carries it.
+
+There is no capability check. `NSHapticFeedbackManager` is a silent no-op
+without a Force Touch trackpad and when the user has feedback switched off, so a
+check here would only be a second place to be wrong — and it would be wrong the
+moment an external trackpad is plugged in mid-session.
+
+## libproc, twice
+
+Two features on this backend want to know something about a process that is not
+us, and on Linux both answers live in `/proc`. Darwin's equivalent is libproc,
+and it answers both.
+
+**A pane's cwd** (`look.shellCwd`) is `readlink("/proc/<pid>/cwd")` there and
+`proc_pidinfo(PROC_PIDVNODEPATHINFO)` here. The tag shows it and a relative
+`Look` resolves against it, so it has to follow the shell rather than stay
+where the pane was spawned.
+
+*When* it is read differs from the other two shells, and deliberately. The tty
+and SDL hosts poll every pane every frame; here the drain has just finished
+saying exactly which shells produced bytes, and nothing else can have moved
+one — a `cd` is a command, and a shell that ran a command writes at least its
+next prompt. So `refreshCwds` reads only for panes flagged by that tick's
+output and an idle session costs no syscalls at all. `test/macos-snapshots/
+cwd.snap` holds the gating to it: the tag must be right after a `cd` and must
+survive a tick with nothing in it.
+
+**A pardes inside a pardes** (`src/nested.zig`) walks the ancestor chain
+looking for our own executable, and hands the file over rather than stacking a
+second full-screen UI inside a pane. `readlink("/proc/<pid>/exe")` becomes
+`proc_pidpath`, and the `PPid:` line of `/proc/<pid>/status` becomes
+`proc_bsdinfo.pbi_ppid`. That struct is hand-written, which is a thing to get
+silently wrong: a field ordering that puts something else where `ppid` should
+be still returns a plausible number, so a unit test compares `parentOf(getpid())`
+against `getppid()`.
+
+The socket half needed real portability work rather than a second spelling.
+Darwin has no `SOCK_CLOEXEC` and no `accept4`, so the flag is set with an
+`fcntl` after the fact — a race only against a fork on another thread, and both
+callers are past that. `sun_path` is 104 bytes here against 108 there, so no
+buffer in the file spells a number any more; they are all sized from the field
+itself, and an address that does not fit is refused rather than truncated into
+a path pointing somewhere else.
+
+Identity gained a third sibling. `bin/pardes` and
+`pardes.app/Contents/MacOS/pardes` are one build installed twice and share no
+directory at all, so the comparison is made at the *install prefix* — the
+directory holding the `.app`, or the parent of a `bin` — and the bundle's name
+can never carry the `-os-arch` tail the installed binary does, because
+`CFBundleExecutable` is a fixed string. `zig build -Dplatform=macos` then
+`pardes src/foo.zig` inside the app's own shell opens a pane in the app.
+
+## Fonts and zoom
+
+The face is the shell's business and the size is the window's, so the two are
+reached differently on purpose.
+
+`Font <name>` and the `FontSel` picker are ordinary core builtins, enabled by
+`pardes.font_picker` — the frontends that draw their own text, which is now the
+SDL shell and this one. `src/fonts.zig` moved out of `gui/` for that reason. It
+walks the platform's font directories and reads four small sfnt tables per file
+to decide whether every glyph has the same advance; no fontconfig and no
+CoreText, so both shells agree about which faces exist and disagree only about
+how to rasterize one.
+
+macOS needed two things from that walk. Its directories are
+`/System/Library/Fonts`, that plus `Supplemental`, `/Library/Fonts` and
+`~/Library/Fonts`; and a third of what is in them — Menlo and Courier
+included — is a `.ttc` collection rather than a plain face. A collection is a
+`ttcf` header in front of several sfnt directories, and the table offsets
+inside one are absolute from the start of the file, so reading face 0 is a
+matter of finding where its directory begins and changing nothing else.
+
+The answer crosses the ABI as a PATH, not a family name: the core already found
+the file, and asking CoreText to resolve a name would be a second lookup that
+can disagree. `pardes_font_take` hands it over once, the same take-and-clear
+shape as the haptic, and the view loads it with
+`CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a
+collection, derives bold and italic from it, and re-measures. A file it cannot
+wear leaves the screen exactly as it was — a terminal that cannot draw has no
+way back out of itself.
+
+Zoom does not touch the core at all. Cmd+, Cmd- and Cmd+0 change the point size,
+`Metrics` is rebuilt, and the new cell is reported through the same resize path
+a window drag uses; the core reflows to a different number of columns and knows
+nothing about points. Cmd+= rather than Cmd++ because AppKit matches the
+character and `=` is what is under the finger.
+
+Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two
+different kinds of assertion because a snapshot is the core's cell buffer and
+the core has no font: `font Menlo-Regular` asks the view what it is actually
+wearing, and the snapshots catch the grid moving when the cell changes size.
+
+### The cell is snapped to device pixels, not to points
+
+The grid has to land on whole *device* pixels: the background pass runs with
+antialiasing off (touching fills would otherwise seam at every shared edge), so
+a fractional column boundary makes the rounding wobble by a pixel from column
+to column, and a screen made of tag bars and selections stripes visibly.
+
+That used to be spelled as whole *points*, which on a Retina display asks for
+twice what it needs — half a point already *is* a whole pixel at 2x. The
+difference is not academic. Monaco advances 8.4014pt at 14, so ceiling to 9
+spaced every column **7.1% wider than the face was drawn for**: loose,
+washed-out text that reads as bad rendering rather than as bad spacing.
+`Metrics` now rounds onto `backingScaleFactor`, giving 8.5 — +1.2%. Width
+rounds to nearest (a monospace glyph is drawn to fit its own advance, so the
+half-pixel either way is slack); height rounds up, because losing a pixel off a
+descender is clipping. The ascent is snapped too, so the rules hung off the
+baseline are whole-pixel fills rather than one-pixel bars smeared across two.
+
+The snap is display-dependent, so `viewDidChangeBackingProperties` re-measures:
+a scale change moves no bounds and therefore fires no resize.
+
+What is *not* done, because macOS does not do it: hinting. Apple renders
+outlines faithfully and lets stems fall where they fall, which is why Mac text
+is softer than a hinted Linux or Windows grid, and why `setShouldSmoothFonts`
+is pinned off — smoothing dilates glyphs (measured: +25% lit pixels, +31% ink
+mass) and needs to know the colour behind the glyph, which over a transparent
+theme it cannot.
+
+## Pixel attachments: PDFs and images
+
+`Surface.images` used to be dropped on the floor here, which is why a PDF pane
+showed *nothing at all*: with `native_images` false the core assumes a terminal
+that cannot draw pixels and degrades a document to counted page turns, and this
+host never set it. It does now — this shell draws pixels, which is a fact
+rather than a question (the tty backend has to ask the terminal about
+kitty-graphics support; the SDL one just says yes, as we do).
+
+The transport is `pardes_image_s`, walked with `pardes_frame_images` /
+`pardes_frame_image_list` after each `pardes_frame`, and it is deliberately
+flat: no callbacks, no handles to register or release. Each entry is a
+rasterized page or image plus two rectangles — `src` (the crop of the raster)
+and `dst` (where it lands), both already clipped to the viewport by the core,
+which is what lets a host draw a continuous-scroll page without inventing an
+overflow clip. Geometry is in **physical pixels**, the space `pardes_resize`'s
+`cell_w`/`cell_h` put the core in; only `cell_x`/`cell_y` are in cells.
+
+`serial`, `page` and `revision` together are the cache key, and the point of it
+is what does *not* move them: panning, zooming to fit and scrolling all reuse
+the same raster, so `PardesView` decodes a page once and scrolling costs
+nothing but a `CGContext.draw`. The bytes the core lends are only valid until
+the next `pardes_frame`, so the `CGImage` owns a copy — which is exactly why
+the key has to be good enough that the copy happens when MuPDF re-rasterizes
+and never on an ordinary wheel event. Attachments a frame does not place are
+evicted, or a session that scrolled a long document would hold every page it
+ever showed.
+
+Two details the picture depends on. The pane BODY is still the clip even though
+the geometry is pre-clipped — a page one pixel too tall would otherwise sit on
+a tagline. And `isFlipped` gives a y-down CTM while `CGImage` draws +y up, so
+each attachment is flipped about its own destination rect rather than about the
+view, which keeps the arithmetic in the grid's coordinates.
+
+Turning this on also changes what an IMAGE pane is here: it was the PETSCII
+glyph-art fallback, the same one a terminal without kitty graphics gets, and it
+is now the real pixels.
+
+## Themes, live
+
+Two bugs lived here, and they were the same bug.
+
+`ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick`
+event. The tty and SDL loops call `core.update(.tick)` on their own clocks;
+this host has no loop of its own, so nothing advanced it — `pardes_tick`
+drained ptys and reported `themeAnimationActive()` back without ever stepping
+the transition. The fade therefore never moved and never ended: every tagline
+kept the *previous* theme's colours until the next launch, and the 16 ms
+re-pump in `AppDelegate.pump` spun at 60 Hz for the rest of the session. The
+pump is the clock, so `pardes_tick` steps it.
+
+`pardes_theme_bg` is the other half. The window background behind the titlebar
+and behind a live resize was a hand-agreed `#121212` in two files; it is now
+read from the core, and it carries the theme's *own* background rather than the
+chrome's, because document backgrounds switch the instant the theme does while
+chrome fades. `window.appearance` follows its luminance, so wearing `acme` no
+longer leaves a dark titlebar over a cream grid.
+
+### Transparent themes
+
+A theme with `bg = null` — the curated `dark`, and every vendored
+`*_transparent` — declares no background of its own. In a terminal that means
+"wear whatever the terminal is wearing"; a window has nothing to wear, so
+`pardes_theme_bg` answers `PARDES_COLOR_DEFAULT` and the host goes see-through:
+`window.isOpaque = false`, a clear background colour, and an
+`NSVisualEffectView` (`.underWindowBackground`, `.behindWindow`, `.active`)
+behind the grid. `PardesView` stops painting the ground at all — it *clears*,
+because AppKit does not blank a non-opaque view — and any cell whose background
+is still the default resolves to `bgClear` and is skipped by the run loop.
+Reversed cells are not: a reverse puts the text colour in the background, and
+text is a real colour that paints.
+
+The blur is a **sibling** of the grid inside a plain container, never its
+parent. Hiding a superview hides its subviews, so a nested backdrop drew a
+blank window for every opaque theme the moment it was hidden.
+
## Threading
One core, touched only from the main thread, plus one pty reader task per pane.
@@ -149,66 +551,137 @@ zig build -Dplatform=macos
```
produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h`
-beside it. This is ordinary Zig cross-compilation and runs anywhere, which is
-the whole point of the next section.
+beside it.
+
+`-Dplatform=macos` is the one platform that overrides the repo's default
+target. Everything else defaults to the Steam Deck (x86_64 linux-gnu, glibc
+pinned low), and that default is not survivable here: swiftc links this archive,
+so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app
+link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the
+default becomes the host arch at `macos_min_version`, which is the same triple
+the app's swiftc link is given, so the two halves of the app cannot disagree about
+how old a macOS they support. On any other host it stays plain native, which is
+what keeps the Linux dev loop below runnable.
+
+That archive is also *fat*. `b.addLibrary` emits only this module's own objects;
+MuPDF, tree-sitter, zstbi, ZLS and ghostty-vt's simdutf/highway stay in archives
+of their own that zig would normally hand to a linker it drives itself. swiftc
+drives this one and is given a single file, so `fatArchive` in `build.zig` walks
+`getCompileDependencies` and folds every static archive into one with Apple's
+`libtool`. This is ghostty's `CombineArchivesStep` minus the non-Darwin half,
+and it inherits ghostty's two hard-won details: each input is copied and run
+through `ranlib` first, because ld64 otherwise refuses zig's layout outright
+(`64-bit mach-o member 'compiler_rt.o' not 8-byte aligned`) and libtool silently
+*drops* members from it — a 15 MB input came back as 13 MB with half the objects
+missing, which links almost far enough to look like a source problem.
```sh
-zig build macos-app -Dplatform=macos # on a Mac; or run the script directly
+zig build -Dplatform=macos # leaves a signed zig-out/pardes.app
+zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over
```
-runs `src/macos/build-app.sh`, which is the whole second half:
+The bundle is assembled by `build.zig` itself, not by a script it shells out
+to. An `.app` is a directory with a plist, a binary and an icon in it, and each
+of those is one step whose inputs the build graph knows — so the app rebuilds
+when a Swift source or the archive moves and is left alone when nothing does.
+There is no Xcode project: a hand-written `pbxproj` would be a second build
+system to keep in step for what three `addInstallFileWithDir` calls already do.
+
+- **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed
+ `src/macos/Info.plist` and writes a stamped copy into the cache. The source
+ file is never mutated, which is what the old in-place `PlistBuddy` call did.
+- **The icon.** The mark is **Glenda**, the Plan 9 rabbit — pardes is an acme,
+ and acme is Plan 9's. `src/macos/icon.swift` is compiled alone (it is
+ top-level code: one file, one module) and run with the bundle's `Resources`
+ as its output directory. She is *drawn*, not traced: four overlapping
+ ellipses filled as one path under nonzero winding for the silhouette, three
+ more punched back out in the ground colour for the eyes and nose. A
+ silhouette rather than an outline because the mark has to survive being
+ twelve pixels across, where an outlined drawing is a grey smudge with a
+ lighter grey inside it — the ears are the whole recognition, and they are the
+ shapes that reach furthest from the mass. Generated rather than committed, so
+ the palette stays in step with the one `PardesView` draws with (ground
+ `defaultBG`, Glenda `defaultFG`, the strip above her the tag bar, the block
+ cursor at the end of it `ansi16[11]`), and there is no binary blob in the tree
+ to disagree with the app it ships in.
+- **The link.** The same swiftc invocation as before, with the optimize mode
+ following `-Doptimize` so both halves of the app are built the same way:
```sh
-swiftc -O -import-objc-header src/macos/pardes.h \
- -o zig-out/pardes.app/Contents/MacOS/pardes \
- src/macos/Sources/*.swift \
- zig-out/lib/libpardes.a -lc++ \
+swiftc -O -target arm64-apple-macos13.0 \
+ -import-objc-header src/macos/pardes.h \
+ -o <cache>/pardes \
+ src/macos/Sources/{main,AppDelegate,PardesView}.swift \
+ <cache>/libpardes.a -lc++ \
-framework AppKit -framework CoreText -framework CoreGraphics
```
-plus copying `Contents/Info.plist`, and the bundle is done. The header goes in
-through `-import-objc-header` rather than a module map, because the header is
-read straight out of the source tree and there is nothing to stage; a module map
-is what an *xcframework* needs, and there isn't one. `-lc++` is there because
-ghostty-vt pulls in simdutf and highway, which are C++ — the Zig side bundles
-`compiler_rt` and `ubsan_rt` into the archive (`bundle_compiler_rt` in
-`build.zig`), so the C++ runtime is the only thing left for this link to supply.
-Without that bundling the swiftc link ends in undefined symbols, which is the
-first thing ghostty's `GhosttyLib.initStatic` does too.
+The header goes in through `-import-objc-header` rather than a module map,
+because the header is read straight out of the source tree and there is nothing
+to stage; a module map is what an *xcframework* needs, and there isn't one.
+`-lc++` is there because ghostty-vt pulls in simdutf and highway, which are
+C++ — the Zig side bundles `compiler_rt` and `ubsan_rt` into the archive
+(`bundle_compiler_rt` in `build.zig`), so the C++ runtime is the only thing left
+for this link to supply. `-target` is not optional: without it swiftc uses the
+host triple, `LC_BUILD_VERSION` records whatever macOS built the thing, and dyld
+refuses to launch it on anything older — the plist's `LSMinimumSystemVersion` is
+a claim, not the enforcement. The deployment version is spelled once, as
+`macos_min_version` in `build.zig`, and reaches the `-target`, the plist and the
+library's own target from there.
+
+## Distribution
+
+`codesign` runs last, over the finished directory — a signature taken before
+the icon lands is a signature the icon then breaks — and it is part of the
+ordinary build, because an unsigned arm64 bundle does not launch at all. The
+default identity is ad-hoc (`-`), which needs no keychain and is enough for the
+machine that built it. For a bundle that leaves this machine:
+
+```sh
+zig build macos-dmg -Dplatform=macos -Doptimize=ReleaseFast \
+ -Dmacos-identity="Developer ID Application: Your Name (TEAMID)"
+```
+
+A real identity also gets `--options runtime` and `--timestamp`, which are
+notarization's requirements rather than a signature's (`--timestamp` on an
+ad-hoc signature is an error, which is why it is conditional). `macos-dmg`
+wraps the signed bundle in a compressed read-only UDZO image — the format every
+Mac already knows how to open, and the signature survives the copy out of it.
+
+Notarization itself is one command away and deliberately not wired in, because
+it needs credentials and the network: `xcrun notarytool submit zig-out/pardes.dmg
+--keychain-profile <profile> --wait`, then `xcrun stapler staple`.
-No Xcode project, no xcframework, no `lipo`, no codesigning — ghostty has all four
-(`macos/Ghostty.xcodeproj`, `src/build/GhosttyXCFramework.zig`, the entitlements
-files), and every one of them exists for *distribution*: a universal binary for
-two architectures, a framework other targets can consume, a signature and
-notarization for Gatekeeper. A dev build that runs on the machine that compiled
-it needs none of it. They are the named upgrade path, in that order: `lipo` when
-a second architecture matters, an xcframework when something other than this app
-links the core, codesigning and entitlements the day it is handed to someone
-else.
+Still not here: an xcframework and `lipo`. Ghostty has both
+(`src/build/GhosttyXCFramework.zig`), and they exist for a universal binary and
+for letting something other than this app link the core. This ships arm64.
-## The Linux dev loop
+## Testing
-The Zig half of this backend is plain POSIX. `forkpty`, `read`, `write`,
-`ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty backend by a string
-constant. So `-Dplatform=macos` compiles on a Linux host, and its tests run
-there:
+Three layers, and each one exists because the layer above it cannot reach where
+it goes.
+
+**The Linux dev loop.** The Zig half of this backend is plain POSIX. `forkpty`,
+`read`, `write`, `ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty
+backend by a string constant. So `-Dplatform=macos` compiles on a Linux host,
+and its tests run there:
```sh
zig build unit-test -Dplatform=macos
```
-Only the Swift app needs a Mac. That is what makes this scaffold verifiable
-rather than dead code: the ABI's Zig side, the pty plumbing and the effect drain
-are all exercised on the machine they were written on, and the part that cannot
-be is small, visible, and made of AppKit calls.
+That covers the ABI's Zig side, the effect drain, and the two quantizers the
+trackpad depends on — `takeScrollTicks` and `takeRotationNotches` are pure
+functions precisely so that "how many search steps is a 180° twist" is answerable
+on a machine with no trackpad in it.
The header is kept honest with ghostty's trick. `build.zig` runs `translate-C`
over `src/macos/pardes.h` into the unit-test build, and `src/macos.zig`
asserts every constant and every struct layout against the Zig side — the color
-tags, the attribute bits, the key codepoints, the mouse and modifier ordinals,
-`@sizeOf(pardes_cell_s)` and each field offset. A hand-written header is a
-second source of truth, and the only defensible way to keep one is a test that
-fails the moment the two disagree.
+tags, the attribute bits, the key codepoints, the mouse, modifier and haptic
+ordinals, `@sizeOf(pardes_cell_s)` and each field offset. A hand-written header
+is a second source of truth, and the only defensible way to keep one is a test
+that fails the moment the two disagree.
A second test compares every exported function's arity and scalar widths against
the header's declaration. It is not a type equality — `translate-C` spells
@@ -216,7 +689,80 @@ pointers `[*c]` and mints its own struct types, so nothing would ever match
exactly — but arity and width are what actually break. It earned its place
immediately: `pardes_scroll` grew a cell coordinate after the Swift view had
already been written against the one-argument form, and nothing but a human
-reading both files would have caught it.
+reading both files would have caught it. It earned it a second time when the
+same function grew a horizontal axis.
+
+**The offscreen AppKit suite.** Everything above stops at the ABI. This one
+drives the real `PardesView` in a real (borderless, offscreen, activation-
+prohibited) `NSWindow`, over a real core with real ptys:
+
+```sh
+zig build macos-e2e -Dplatform=macos # run it
+zig build macos-e2e -Dplatform=macos -- --update # regenerate the goldens
+zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap
+```
+
+`test/macos_e2e.swift` links the same Swift sources the app does, minus
+`main.swift`, into a second binary — test scaffolding does not ship inside the
+product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's
+vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`) plus what only
+exists here: `fingers <n>`, `force`, `rotate <degrees> [gap_ms]`, `rotate_end`,
+`drop <path> <col> <row>`,
+`scroll`, `haptic <none|exec|look>` and `draw`. The dial's two extras are what
+make momentum testable at all: the optional gap is a real sleep before the
+event, so a script can say how FAST the dial is being turned, and `rotate_end`
+is the release the fling is measured from. Output is byte-identical in shape to
+`test/snapshot.zig`'s, so a grid captured through CoreText and one captured
+through a pty can be read side by side.
+
+This is the layer that can assert the trackpad features, and the reason it can
+is that `NSTouch`, pressure stages and rotation have **no public
+constructors** — a test can never synthesize the events. So the view is built
+with the decision one call below the event: every override decodes and then
+calls `press`/`release`/`click`/`rotate`/`typeKey`, and `Trackpad.button(fingers:)`
+is pure policy with no `NSEvent` in it. The scripts drive those, which is
+everything except the two lines that read the properties off the event. `haptic`
+reads `pardes_take_haptic` back, which is how a pulse is asserted on a machine
+that cannot feel one; `draw` renders the view with `cacheDisplay` and fails if
+every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap`
+reads the core's cell buffer and would be perfectly happy with a `draw` that
+returned on its first line.
+
+Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the
+config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and
+`TMPDIR` inside the per-script world so that `New`'s document has a reproducible
+directory — its six mkstemp characters are masked on capture.
+
+**The app itself.** `zig build -Dplatform=macos && open zig-out/pardes.app`.
+Some things only a hand can test: which System Settings checkbox is on, what a
+deep press feels like, whether the haptic lands with the click or after it.
+
+## Performance
+
+Measured on an M2, one window at 190x56 (1710x984 points), timing `draw(_:)`
+and its phases over 60 frames of a shell pouring out four thousand lines.
+
+| | Debug core | ReleaseFast core |
+|---|---|---|
+| `pardes_frame` | 4119 us | 413 us |
+| background pass | 160 us | 187 us |
+| glyph pass | 226 us | 264 us |
+| **whole `draw`** | **4516 us** | **879 us** |
+
+The finding is the first row, and it is not about drawing at all. `swiftc` was
+hardcoded to `-O` while the Zig core followed `-Doptimize`, so the ordinary
+build shipped an optimized shell wrapped around a Debug core — and that reads
+as "the mac backend is slow" rather than "you built Debug". The mode now
+travels from `-Doptimize` into the swiftc link, both halves are compiled the
+same way, and a Debug bundle says so on the way out. Build one you intend to
+*use* with `-Doptimize=ReleaseFast`.
+
+What is left is honest: 0.88 ms against a 16 ms frame, and the Swift half is
+0.45 ms of it. Nothing here is a CoreText problem yet. The two things that
+would be worth doing before reaching for Metal, if a bigger window ever makes
+this matter, are both in `pardes_frame` rather than in the view — it re-renders
+every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect`
+for exactly that reason.
## Not implemented
@@ -233,20 +779,35 @@ reading both files would have caught it.
`Build/Watch/FsEvents.zig`. Without it the core simply never receives
`file_changed`, a state it tolerates because the browser has no filesystem
either.
-- **IME and marked text.** Only finished characters reach `pardes_key`. Real
- composition means implementing `NSTextInputClient` and giving the core a way
- to render an underlined preedit run, which no backend has yet.
-- **Tabs and splits at the window level.** One window, one grid. Pardes's own
- columns and panes are the layout, and a second window would need a second
+- **IME and marked text.** Only finished characters reach `pardes_key`, so a
+ dead key composes nothing and Option is Alt rather than a compose modifier.
+ Real composition means implementing `NSTextInputClient` *and* giving the core
+ a way to render an underlined preedit run, which no backend has yet — the
+ second half is why this is not just an AppKit protocol away.
+- **Tabs and splits at the window level.** One window, one grid; window tabbing
+ is switched off rather than left to produce an empty second window. Pardes's
+ own columns and panes are the layout, and a second window would need a second
core, which the singleton ABI is precisely a decision not to have yet.
-- **Native image and PDF placement.** `Surface.images` is ignored. The tty
- backend draws these with Kitty graphics and the SDL one with GPU textures;
- macOS would be a third path, a `CALayer` or `CGImage` per placement positioned
- from the cell rectangle. `pardes_resize` already carries the physical cell
- size that path needs.
-- **App-bundle resources.** The plist is the minimum that makes a windowed app:
- no icon, no bundled fonts, no asset catalog, no localization.
-- **argv and file-open handling.** No positional path, no `-l` dump load, and no
- `application:openFile:`. The first pane spawns with an empty cwd, so the shell
- inherits the process's — which for a bundle launched from Finder is `/`, and
- is the first thing worth fixing here.
+- **A glyph atlas.** Drawing is CoreText per row: runs of cells sharing a face
+ and a colour go out as one `CTFontDrawGlyphs`, ASCII glyph ids are resolved
+ once per face at init and everything else is cached on first sight. That is
+ enough for a grid this size, and it is still a cmap-and-rasterizer path where
+ the SDL shell has a 2048² atlas. It has now been profiled rather than
+ guessed at (see Performance): the glyph pass is 264 us of an 879 us frame,
+ which is not where the time is, so the escalation is still not warranted.
+ When it is, it is ghostty's: a `CAMetalLayer` installed into the view and
+ driven from Zig, with the ABI growing one `platform` pointer field.
+- **A Tahoe icon asset.** `src/macos/icon.swift` emits a full-colour `.icns`,
+ every one of the ten sizes, and that is the correct and only format at a 13.0
+ deployment target. macOS 26's Dock defaults to the `ClearAutomatic` icon
+ style, which desaturates any icon that does not ship the new appearance
+ variants, so ours renders there in grey while apps built with Icon Composer
+ keep their colour. Matching them means an `Assets.car` produced by an Xcode 26
+ tool, which is the first thing in this backend that would actually require
+ Xcode — hence not done. The file itself is verifiably correct: `iconutil -c
+ iconset` round-trips all ten, and the tag bar is `#3465A4` at every size.
+- **Distribution.** Ad-hoc codesigning and a DMG are wired in; a Developer ID
+ is one `-Dmacos-identity=` away and notarization one `notarytool` call. Still
+ absent: a universal binary, bundled fonts, localization. The app has an icon,
+ a plist that says what it opens, a deployment target it actually enforces and
+ a signature; the arm64-only slice is the deliberate remaining gap.