summaryrefslogtreecommitdiff
path: root/docs/macos.md
blob: b16661345389290844e659086c2644315fcc114f (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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# Native macOS backend

Zig owns the core, the ptys, every effect and the worker threads. Swift owns
`NSApplication`, the window, input translation, and drawing. They meet at a
hand-written C ABI, `src/macos/pardes.h`, built as a static library that the app
links; the Swift half is ordinary AppKit that pumps events in and reads one
packed cell buffer out. There is exactly one core per process, so the ABI has no
handles — the state is a file-scoped singleton and every function names it
implicitly, exactly like the browser backend.

The research this design came out of is written down separately in
`docs/ghostty-macos-notes.md` — how ghostty wires Zig to Swift, what its build
plumbing actually requires, and which parts of it are distribution machinery
rather than integration. Read that for the alternatives; this file is what was
built and why.

## Why not ghostty's split

Ghostty was read carefully before this was written, and this backend
deliberately inverts its division of labour. Ghostty hands Zig a bare `NSView*`
through a tagged platform union (`ghostty_platform_macos_s.nsview`), and Zig
then creates a layer, assigns it as the view's layer, sets `wantsLayer`, and
installs a display callback — `src/renderer/Metal.zig` in that tree. Swift never
renders a glyph; it supplies a rectangle and gets out of the way.

Pardes goes the other way because its frame is *already* a cell grid.
`pardes.Surface` is `cols × rows` of `Cell`, and CoreText is the native way to
draw one: attributed runs, the system font stack, and Apple's own subpixel and
color-emoji handling, for free. The alternative is a hand-rolled glyph atlas,
and the SDL backend is the measurement of what that costs — roughly three
thousand of `src/gui/gui.zig`'s 4,300 lines are a 2048² FreeType-hinted R8 atlas,
GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys
nothing the grid needs.

Blind is the operative word: this backend was scaffolded on a Linux machine. A
Metal renderer written there would have been untestable code — compiled at best,
never once run — whereas CoreText drawing is a small amount of Swift that only
exists on the machine that can run it.

The ceiling is accepted and named. Per-cell CoreText drawing is slower than an
atlas, and if it fails to hold a full-screen redraw at the target rate the
escalation is ghostty's model verbatim: Zig owns a `CAMetalLayer` installed into
the view and drives its own frame clock, and the ABI grows a `platform` pointer
field carrying the `NSView*` — one field, because the rest of the seam does not
change. Nothing here is designed to make that harder.

## Why the ABI mirrors src/web.zig

The browser and a Cocoa app are the same host, and the browser proved the shape
first. In both, someone else owns the clock and the event loop; events arrive
through flat functions that take scalars and borrowed byte ranges; one call
renders, and the result is one contiguous array of packed cells the host walks
linearly. `pardes_cell_s` is byte-for-byte `WebCell`: seven UTF-8 bytes of
grapheme plus a guaranteed zero, tagged `fg`/`bg` words (`0x01000000` default,
`0x02000000 | index` for the palette, otherwise `0x00RRGGBB`), an attribute
bitfield with the underline style in the high bits, and a `flags` bit meaning
"the core never painted this cell" so a host can draw background only and skip
the glyph. Two hosts spelling the same encoding is not duplication worth
removing; it is the encoding being right.

The one real difference is IO. The browser has no ptys, so `src/web.zig`
forwards every effect out to JavaScript as a numbered code plus a byte payload,
and JavaScript performs it. macOS has `forkpty`, `read` and `write` right there,
so effects never cross the boundary at all: `pardes_tick` drains the core's
effect queue and performs each one in Zig, the way `drainEffects` in
`src/tty/tty.zig` does. That is why the runtime struct is two callbacks and not
twelve.

## The seam

**Frame.** `pardes_frame` renders and returns the cell count;
`pardes_frame_cells` hands back a pointer valid until the next `pardes_frame`,
with `pardes_frame_cols`/`_rows` giving its shape. The cursor rides alongside as
`pardes_cursor_x`/`_y`, each `-1` when it is hidden, plus `pardes_cursor_bar`
asking for a thin insert caret instead of a block. `Surface.images` has no
representation here yet; see the checklist.

**Input.** `pardes_key` takes a codepoint and the host's already-composed text,
because composition is AppKit's job and the core only ever wants finished
characters. Keys that carry no text are the four ASCII controls (enter, escape,
tab, backspace) and a private-use block starting at `0xF0001` for the arrows and
navigation keys — private-use precisely so a functional key can never be
confused with a real codepoint arriving as text. `pardes_mouse` speaks acme's
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 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_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`
means "your state moved, please pump me"; `set_clipboard` fires inside a tick
when the yank register changes, with the text borrowed for the duration of the
call. There is no `open_url` callback because the core already opens links
itself through `/usr/bin/open` (`src/look.zig`), and no file callbacks because
it owns the filesystem side too.

**Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code,
`pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect
queue and returns whether anything changed, `pardes_should_quit` reports the
Exit builtin or the last pane closing, and `pardes_animating` says a theme
transition wants ~60 Hz ticks until it settles — the one thing in an otherwise
event-driven frontend that redraws on a clock, handled in `tty.zig` by sleeping
the loop and forcing a tick.

The ordering contract is the part a header cannot enforce. **Init with the real
grid size.** The core defers each shell's greeting until it has seen a resize:
`sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0`
and the pty has produced its first prompt. Setting `Options.cols`/`rows` alone
never bumps that counter, so init must turn its arguments into an actual resize
event the way `src/web.zig` does immediately after construction — and the size
must be true, because the first `forkpty` takes its winsize from the core's
current grid and a shell booted at a placeholder draws its first prompt at the
wrong width. **Call `pardes_tick` after every input function.** The input calls
only advance the state machine; the writes to the pty, the spawns, the saves all
happen in the drain, so an input without a following tick is an input that
visibly did nothing. **`wakeup` is the only any-thread entry point in the whole
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 20°, 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° notches,
twelve search steps — the granularity is right, and the reason a twist can look
like it does nothing is that `n` has nowhere to step until a search is armed.

## 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 macos-app` 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.

## Threading

One core, touched only from the main thread, plus one pty reader task per pane.
A reader blocks in `read(2)`, appends into that pane's mutex-guarded buffer, and
calls `wakeup`; the next tick swaps the buffers under the lock and feeds the
bytes in as `output` events. Sixteen panes is the ceiling (`MAX_PANES`), so it
is sixteen threads worst case, each of which does nothing but move bytes.

Ghostty again is the contrast: it runs a renderer thread *and* an IO thread per
surface, each with its own mailbox and wakeup, because it owns the frame clock
and must render independently of input. Pardes does not own the clock here — the
host does, through `wakeup` and dirty rects — so there is no third thread to
synchronize and no mailbox protocol to get wrong.

## Building

Two commands, because they are two different machines' problems.

```sh
zig build -Dplatform=macos
```

produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h`
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
`build-app.sh` gives swiftc, 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
```

runs `src/macos/build-app.sh`, which is the whole second half: it copies
`Contents/Info.plist` and stamps `LSMinimumSystemVersion` from the version
build.zig passed it, compiles `src/macos/icon.swift` and runs it to emit
`Contents/Resources/pardes.icns`, and links the app:

```sh
swiftc -O -target "$(uname -m)-apple-macos$minver" \
  -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++ \
  -framework AppKit -framework CoreText -framework CoreGraphics
```

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.

The icon is generated rather than committed: no binary blob in the tree, and its
palette stays in step with the one `PardesView` draws with, because both read
the same constants.

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.

## Testing

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

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, 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
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. 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>`, `scroll`, `haptic
<none|exec|look>` and `draw`. 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 macos-app -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` in `build-app.sh` while the Zig core followed `-Doptimize`,
so the ordinary `zig build macos-app` 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 as the script's third argument, 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

- **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and
  content taken *before* it starts, as `LspJob` in `tty.zig` does; the core
  keeps editing while a query is in flight. Until then language queries are
  dropped and nothing waits for an answer.
- **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a
  job, a worker to run the command, and a `pipe_resp` event back. Same shape as
  `lsp`, one more response type.
- **The `watch` effect.** `watchPane` is inotify and returns silently off Linux
  (`ponytail:` at `src/tty/tty.zig:1035`). macOS wants the FSEvents half of
  `std.Build.Watch`, which the standard library already has as
  `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`, 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.
- **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.** No codesigning, no notarization, no universal binary, no
  bundled fonts, no localization. The app has an icon, a plist that says what it
  opens, and a deployment target it actually enforces; everything past that is
  Gatekeeper's business and starts with `lipo`.