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
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
|
# 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.
Future layout/compositing ownership and the regression baseline are discussed in
[Shared rendering contract](rendering-parity-design.md). That proposal is design
groundwork; the native drawing implementation remains in place.
## 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 — a large part of
`src/gui/gui.zig`'s 6,348 lines is a 2048² FreeType-hinted R8 atlas (`atlas_w`
and `atlas_h`, `src/gui/gui.zig:140`), 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.
That does not rule out a *postprocess*. CoreText is still the renderer and the
cell ABI is unchanged, but shader effects draw that same frame into a retained
bitmap and feed it through Core Image kernels on one Metal context.
There is no second glyph atlas, pane renderer, or view pointer in the ABI.
## 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 almost no effect crosses the boundary at all: `pardes_tick` runs
`while (core.nextEffect()) |e| core.perform(e)` (`src/macos.zig:1154`) and each
effect lands on this host's own `Host.VTable`, of whose twenty-one methods
fourteen are filled in (`src/macos.zig:1761`).
The machine-local half of those methods is no longer this file's. `forkShell`,
`writeFileBytes` and `writeFd` live in `src/host_io.zig` and are shared with
the tty shell, the SDL shell and the detached daemon; `src/macos.zig` calls
them (`:1807`, `:1823`, `:1848`, `:1863`) and its own copies, together with the
four `extern "c"` declarations they needed (`forkpty`, `execv`, `chdir`,
`_exit`), are gone. Two `extern "c"` declarations remain here: `setenv`, and
`pardes_host_watch_file`, which FileWatcher.swift satisfies
(`src/macos.zig:40`, `:45`). The bug all four hosts' private `forkShell`
copies had in common is the argument for the shared file existing: none set
`FD_CLOEXEC` on
the pty master, so a program in one pane could read and write another pane's
terminal and closing a master did not reliably hang its shell up.
That is why the runtime struct is three callbacks rather than a second vtable —
and why two of the three are the clipboard: the pasteboard is AppKit's, in both
directions, and is the one piece of IO the Zig side cannot reach for itself.
## 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` crosses
separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel
attachments" below.
`pardes_scene` is the matching full-window snapshot: a bit for CRT, plus the
60 Hz time/frame that animates it. It is returned
by value, so the renderer never holds a pointer into live core state. A zero
flags word is also the fast-path contract: draw the CoreText frame directly.
**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 three functions,
copied by value during init so the struct need not outlive the call. `wakeup`
means "your state moved, please pump me". `set_clipboard` hands over text to put
on the pasteboard, borrowed for the duration of the call; it fires inside a tick
for `SPC y` / `SPC Y` and for the tag's own `y` chord, and for
nothing else — an ordinary `y` or `d` writes the core's register and never
reaches here, which is what stopped deleting a character from clobbering the
desktop's clipboard. The other three clipboard words — `SPC p`, `SPC P`,
`SPC R` — go the OTHER way and raise `read_clipboard` instead.
`read_clipboard` is that direction reversed: "give me the
pasteboard", with no payload either way, because the host answers by calling
`pardes_paste` — and NSPasteboard being synchronous, that usually happens inside
the callback itself, before it returns. Both are main thread, inside
`pardes_tick`. 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
`pardes_animation_tick` wants a ~60 Hz call until it settles — the one thing in
an otherwise event-driven frontend that redraws on a clock. A persistent scene
effect deliberately keeps that clock armed until its builtin turns it off.
Ordinary input and pty pumps never count as elapsed animation frames.
The ordering contract is the part a header cannot enforce. **Init with the real
grid size.** The core defers each integrated shell's greeting until it has seen
a resize: `sync()` in `src/pardes.zig` only emits the opening `ls` once
`resize_count > 0` and parsed OSC 133 B says the prompt has handed the cursor
to input. An unintegrated shell has no reliable readiness signal, so it skips
the cosmetic greeting; an explicit command still releases after its successful
fork. 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, cheapest gesture to commonest verb:
| gesture | button | verb |
| --- | --- | --- |
| one finger | 1 | select |
| two fingers | 2 | Exec |
| three fingers | 2 | Exec |
| a deep press | 3 | Look |
| 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, which
is the one thing a multi-finger click here must NOT do. 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.
Two and three fingers landing on the same verb is therefore not a wasted
gesture, it is the hardware refusing to distinguish them under the default
setting. Look is the deep press instead: on a trackpad it is the one gesture
the system does not overload, and it wants "Force Click and haptic feedback"
on in System Settings — which nothing in this process can read, so a Mac with
that off (or a trackpad with no force sensor) reaches Look through a real
mouse's right button and the `Look` builtin, keyboard Enter included.
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 Exec 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 right one
is sent. That ordering is not tidiness: a right press arriving while the core
holds a left select-drag is acme's 1-3 chord, which is **Paste**. The release
costs a cursor move at the click point, which is what clicking there would have
done anyway; a multi-finger press released this way fires the Exec its fingers
already asked for.
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-Look 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.
Twisting two fingers is a dial, and a dial over a list of look-able places is
`n`. A notch moves the SELECTION one place along and opens nothing; Enter opens
the one the dial stopped at, which is what makes the dial worth spinning at all
— a stepper that opened every place it passed could not be. `pardes_rotate`
takes raw degrees and libpardes quantizes them, one 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 is scaled off the EXCESS over a 70°/s floor
rather than being a flat amount the moment the floor is crossed:
```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. There is a second floor under that one: an excess below
`rotation_fling_stop` (18) coasts at zero, so 70–88°/s is a dead band and the
smallest coast that happens at all is 18°/s — under a fifth of one notch, which
is why a slow deliberate turn still spends no notches at all. 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_animation_tick`, one fixed 1/60 step per
scheduled frame 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
places rather than three for a reason worth keeping, and `n`/`N` becoming a
RING only sharpened it: a short ring comes round, so a hard flick can stop
exactly where a single notch would have and the two screens agree — a golden
that passes before momentum exists. Twenty-four is longer than the cap can
travel (about eleven), so the fling has nowhere to hide.
## 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. (One piece of
document chrome does exist, and it is the harmless one: File ▸ Open… runs a
real `NSOpenPanel` and hands what you pick to Look.) 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 one core watermark. `File` counted `revision` but never
recorded which edit was last *written*, so no shell could derive "unsaved" —
`File.saved_revision` is that watermark, advanced by `Save` at the moment the
write is asked for and by a successful external-file reload whose bytes
already came from disk. The core shows the same answer on each pane's grip
button, while this host also puts it in the native close button.
Save is marked at ask-time rather than on completion because `save_file`
carries none back, which makes both indicators exactly as honest as the Save
request.
## The menu bar, and the chords the core never sees
A Mac app without a menu bar is a Mac app that is wrong, and most of what a
menu bar wants to say pardes already has a word for. So the menu is mostly a
second spelling of builtins — `AppDelegate` calls `run("New")`, `run("Save")`,
`run("Help")`, `run("Tutor")` — and the items with no builtin behind them are
left out rather than stubbed.
The workspace tag row — the topmost tagline, the one carrying `Newcol Joincol
Find Grep …` — is not drawn on this shell. `-Dworkspace-tag` (default off for
`-Dplatform=macos`, on everywhere else) hands the row to the menu bar: the
core stops reserving the row (`Pardes.topBarHeight`), the grid starts at the
column tags, and the row's commands live in a **Builtins** menu instead. The
items are the tag's own words, spelled exactly as a tag Exec would type them,
and nothing in that menu is editable — it is the tag's buttons, not the tag:
| menu | item | chord | what it does |
|---|---|---|---|
| pardes | About pardes | — | AppKit's panel |
| | Hide pardes / Hide Others / Show All | ⌘H / ⌥⌘H / — | AppKit |
| | Quit pardes | ⌘Q | AppKit |
| File | Open… | ⌘O | a real `NSOpenPanel`, then Look on what you picked |
| | New | ⌘N | the `New` builtin |
| | Save | ⌘S | the `Save` builtin |
| | Close Window | ⌘W | AppKit; the app terminates after the last one |
| Builtins | Newcol / Joincol / Find / Grep / Changelog / Dump / NextColor / Debug / Kill | — | the workspace tag's own builtins, `run(word)` |
| Edit | Paste | ⌘V | the view's own paste path, not a second one |
| View | Zoom In / Zoom Out / Actual Size | ⌘= / ⌘- / ⌘0 | point size, host-side |
| Window | Minimize / Zoom | ⌘M / — | AppKit |
| Help | pardes Help | ⌘? | the `Help` builtin |
| | Tutorial | — | the `Tutor` builtin |
Edit holds Paste and nothing else. Copy, Cut, Undo and Select All are
deliberately absent: pardes's own words for those are `SPC y`, the 1-2 chord,
`u` and `%`, and a menu item that ran a different thing under the same name
would be worse than no item.
The consequence is worth stating plainly, because it is invisible from the
core's side: those chords are now **the menu's**, and the core can never see
them. Nor can it see any other Command chord — the ABI's modifier mask carries
ctrl, alt and shift and has no super bit, so `PardesView` swallows Cmd rather
than sending a key the core would misread as unmodified. Cmd is the host's
layer here; everything pardes binds lives on the other four.
## 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** (a deep press) 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** (two or 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) moves the SELECTION to the next look-able place and opens
nothing; Enter opens what it landed on. It walks a ring across panes — the
ones a Look came from first, then the output buffers none has — so a twist
with no results buffer anywhere still steps the pane in front of you. A screen
with no filename on it is what has nothing to step.
Setting `PARDES_LOG` at all — the gate tests presence, not value, so even
`PARDES_LOG=0` counts — 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 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. A twist taps nothing
now that `n`/`N` only move the selection, and that is the honest report: the
detent belongs to the Enter that opens, and that is where it fires.
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.
## Shell directories and nested Look
`host_io.shellCwd` reads a shell's working directory using
`proc_pidinfo(PROC_PIDVNODEPATHINFO)`; Linux uses `/proc/<pid>/cwd`.
The macOS host refreshes directories for panes that produced output, so idle
frames do not poll every shell. `test/macos-snapshots/cwd.snap` covers the tag
after `cd` and after an idle tick.
Nested launches use the same inherited 9P address and pane serial as other
native hosts. They do not inspect ancestor processes or executable names.
See [the control filesystem](fs.md) for paths and transports.
Two more things this host cannot inherit from its launcher, because a `.app`
has none:
- **The terminal's identity.** `host_io.ChildEnv` writes `TERM`, `COLORTERM`
and `TERM_PROGRAM` over whatever the environment carried and the child is
`execve`'d with that array — built before the fork, because a `setenv`
between fork and exec can deadlock on the heap a pty reader thread was
holding. Every pane is emulated by the bundled VT, so the value describes
pardes and never the terminal pardes was started from; `-Dplatform=gui` and
the tty shell take the same path, where the difference is only that their
launcher usually happened to set something. The names come from
`config.child_term` / `child_colorterm` / `child_term_program`, and `TERM`
is deliberately `xterm-256color` rather than a name with no installed
terminfo entry — `clear`, colour and cursor addressing all resolve through
that lookup.
- **Whether a program has the terminal.** `host_io.ttyTaken` is what
`Pardes.takesCommandLine` asks before deciding that Escape is the `Last`
builtin rather than a keystroke for the child, and what `Exec` asks before
believing a pane is at its prompt. Linux descends `/proc/<pid>/task/<pid>/
children`; darwin has neither that nor a children list, so it enumerates the
tty's foreground process group with `proc_listpids(PROC_PGRP_ONLY)` and
compares each member's `proc_pidpath` against the shell's own. A nested
interactive shell is therefore still a prompt, and `bash -c 'sleep 30'` is
still taken — the wrapper wears the shell's binary, but `sleep` shares its
group. The occupancy suite in `src/host_io.zig` runs on both platforms.
## 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 `Fonts` 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 crosses back through `pardes_font_reject` and leaves the old metrics
exactly as they were. Success crosses through `pardes_font_ack` with the
effective PostScript name and point size. Requested and effective values are
therefore distinct, queryable facts rather than a request being mistaken for
what is on screen.
Cmd+=, Cmd- and Cmd+0 change the point size, rebuild `Metrics`, and report the
new cell through the same resize path a window drag uses. They also observe the
effective face/point tuple through `pardes_font_observe`, so the single `DumpConfig`
report follows a host-owned zoom without resolving a pending face request. The
initial system face is observed the same way. 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.
### Compact tags and context rows
`TaglineSize <percent>` scales the tag face, pitch and glyph band. The tag's
background still fills a whole body-grid row, as in the SDL renderer. Painting
only the compact band leaves dark strips between tags. Glyph offsets come from
`pardes_tagline_band_offset`; all measurements cross the ABI in physical pixels.
The chrome overlay runs after the compact layers. It draws the topbar rule,
the column rule and the pane's tag/body boundary using the theme's border
colour. The pane rule moves above a bottom-positioned tag. Tag-layer fields 11
and 12 preserve that placement and colour in frozen transition frames.
Tree-sitter context rows use the compact height and pitch, with full-width row
backgrounds. Their one-physical-pixel separators use the same border colour as
SDL, after discontinuous declarations and after the final context row. The
remaining body starts immediately after the compact rows.
### 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.
## Live file reload
`FileWatcher.swift` gives each watched pane a vnode source on the file and one
on its parent directory. The file catches in-place writes; the parent is
essential because editors commonly save by renaming a fresh inode over the
path. After the 45 ms debounce the file source is rearmed on the current inode,
then the event returns to Zig with the pane's watch generation. Replacing or
closing a pane advances that generation, so a callback already queued for the
old occupant cannot reload the new one.
The callback only wakes the ordinary main-thread pump. `pardes_tick` checks the
exact owned path, filtering unrelated changes in the same directory, duplicate
vnode events, and Pardes's own saves. Text panes compare and adopt a bounded
byte snapshot by content hash. PDFs can be much larger than that bound, so they
compare inode/size/time metadata and let MuPDF reopen the pathname directly.
That identity is committed only when equal stats bracket a successful
transactional reopen; a mismatch receives one self-scheduled retry, so it does
not depend on a second vnode edge and cannot spin forever on a malformed stable
file. Reading settings survive and derived page data is regenerated. A
malformed PDF therefore leaves the last good document usable and remains
retryable after the next real write.
## 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; the
native host schedules the same clock explicitly through
`pardes_animation_tick`. `pardes_tick` only drains work: if every input pump
also advanced the transition, a burst of key or pty events could collapse a
ten-frame fade into one display frame. The scheduled callback advances once,
then pumps effects and redraws, and re-arms itself only while
`pardes_animating` remains true.
`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.
## Scene effects
`Crt` is one full-window postprocess. Its canonical macOS source is `shaders/crt.ci.metal`, which
the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift
shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs
it through a `CIContext` created from the system Metal device. The source is
also archived by the Zig build; `EffectCode` links to it without a checkout.
The ordinary CoreText/attachment/cursor pass is one function. With any scene
bit or panel track it targets a retained, backing-scale bitmap; kernels then
sample the complete frame into the flipped view. PDFs, image panes, taglines,
rules, and the caret therefore receive the same effect. With every bit off and
no panel track the bitmap and Core Image context are bypassed entirely. CRT works
in linear light with restrained bloom, scan/mask, vignette, hum, and noise
instead of applying a broad color remap.
The scene kernel alone receives a `clampedToExtent` image and the final output
is cropped back to the original finite extent. Chroma samples therefore clamp to the edge exactly like SDL's scene sampler instead
of acquiring transparent-black seams from Core Image's finite source image.
The Zig side owns the clock. `pardes_animation_tick` increments its wrapped
60 Hz frame only while a scene bit is active, and `pardes_scene` derives seconds
from that integer. Input bursts cannot accelerate the shader.
Pointer input follows the same destination-to-source transform as the last
presented scene frame before it is divided by the cell metrics. The view keeps
that exact `pardes_scene_s` snapshot and mirrors the Metal barrel arithmetic
in `ScenePostprocessor.sourcePoint`; pixels outside
the CRT tube have no cell. The resulting displayed-grid cell then reaches the
core, whose panel-track mapping resolves it to canonical pane content.
## Panel transitions
`pardes_frame_panel_track_list` publishes the core's plain `Track` records
without a host-side animation model: pane serial/id, phase, effect, frame, and
logical-cell `from`/`to` boxes. The C layout and every field offset are asserted
against the Zig extern struct on Linux. The exported list is already stable
paint order—moving panes by slot, then opening panes by slot—so Swift only
consumes it.
CoreText renders the complete frame supplied by the core. Character effects —
PanelAscii's byte walks and the glyph motion of PanelEdges, PanelFall,
PanelWave, PanelCurtain, PanelScramble and PanelType — are already composed
there, identically to the TTY and SDL paths. When tracks
exist, the
same retained bitmap used by scene effects is fed through two kernels in
`shaders/crt.ci.metal`: `pardesPanelClear` removes every final target first,
then `pardesPanel` samples each target into its eased presented box. Slide uses
ease-out cubic, zoom uses ease-out-back, dissolve uses stable pane/cell noise,
while `pardesComposedInCore` effects only clip the already-composed core cells;
pixel attachments have no character value and pass through unchanged.
Because the input is the finished bitmap rather than a glyph-only
batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image
panes move and dissolve together. The scene CRT runs once after the panel
composition.
With no scene bit and no panel track the retained bitmap, Core Image context,
and Metal passes are bypassed. `EffectCode Panel*` links to this Metal
file and its Swift owner in the virtual filesystem.
### 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 when `WindowBlur` is enabled. `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.
`WindowOpacity <0..100>` sets one background coverage throughout the frame.
Background fills replace existing coverage, so overlapping ground, cell, tag
and context layers cannot increase the requested opacity. The window itself
stays clear below 100%; a second tinted window backdrop would compound it.
Text and cursor ink stay opaque. PDFs render onto a transparent MuPDF pixmap,
which preserves the coverage of text, paths, photos, and highlights. The host
paints paper at `WindowOpacity`, then composites PDF content at its original
opacity. Blank paper therefore reveals the blurred backdrop while lettering
stays readable even at `WindowOpacity 0`. Paper is white with `PdfTint` disabled
and uses the theme background otherwise. Explicit PDF background rectangles
and scanned page images remain content: this does not guess paper from pixel
brightness or remove white objects from a document. Scrolling and transitions
retain both the content raster and its paper color.
Ordinary image attachments still follow the SDL image pipeline: remove the
destination by source coverage, then add the image at the requested opacity.
Theme colours and attachment rasters are interpreted as sRGB. The PDF paper
separation is currently macOS-specific; other hosts keep the opaque raster path.
`WindowBlur <0..100>` controls the strength of that native backdrop independently
of `WindowOpacity`. It is a macOS-only builtin, also accepted in the startup
config and reported by `DumpConfig`. It defaults to 0 (off); 100 shows the full
AppKit material and intermediate values blend the material with the unblurred
backdrop using the effect view's alpha. An opaque themed background hides the
effect; lowering `WindowOpacity` makes the remembered strength visible again.
For example:
```text
WindowOpacity 71
WindowBlur 60
```
This is material strength, not a Gaussian radius in pixels. AppKit's public
`NSVisualEffectView` API chooses its own blur and tint for the material. It
samples behind the window; text and cursors are drawn in a separate sibling
above the effect and remain sharp. macOS accessibility settings such as Reduce
Transparency can override the material's appearance. `WindowBlur 0` also turns
off the formerly implicit blur for background-less themes; set it to 100 to
restore that appearance. The compositor must be checked in a live window:
offscreen grid captures cannot verify behind-window blur.
`test/macos-snapshots/rendering-parity.snap` checks background colour and alpha,
regular and bottom tags, compact context separators, and PDF fit, tint and
scrolling. Its native-metrics mode uses backing pixels like the shipping app;
the older grid-only tests keep their display-independent point metrics.
## Threading
One core, touched only from the main thread, plus one pty reader task per pane
and one socket listener for the nested-pardes protocol. A reader blocks in
`read(2)` and pushes into ONE process-wide `Inbox` — a bounded ring of tagged
messages, not a buffer per pane — then calls `wakeup`; the next tick drains the
ring wholesale and feeds the bytes in as `output` events. The ring is lossy
under sustained backpressure: an `output` message can be dropped, and `eof` and
`command` will evict a queued `output` to get in, because losing a byte of
scrollback is survivable and losing the end of a pane is not. Sixteen panes is
the ceiling (`MAX_PANES`), and the readers run on a default-sized
`std.Io.Threaded` pool, so the thread count is the pool's rather than one per
pane.
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
One command builds everything this shell has. The two extra steps below exist
only for handing a bundle to somebody else.
```sh
zig build -Dplatform=macos
```
produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h`
beside it — and on a Darwin host a signed `zig-out/pardes.app` as well, which
the next section is about.
`zig-out` and not `~/.local`, and that now needs saying. A bare `zig build`
redirects the install prefix to `$HOME/.local` and prints that it has, but only
behind the guard `also_gui and prefixIsUntouched(b)`: `also_gui` is
`requested_platform == null`, and `prefixIsUntouched` refuses when a `DESTDIR`,
a `--prefix` or a `--prefix-*dir` has already chosen somewhere. `-Dplatform=macos`
names a shell, so neither condition holds and every path in this document is
relative to `zig-out` unless you pass `--prefix` yourself. The `<prefix>/dev`
directory the benchmark binaries install into likewise never appears in this
backend; nothing here is a dev binary.
`-Dplatform=macos` is one of the two platforms that override the repo's default
target; `esp32p4` is the other, and pins its own riscv32-freestanding query.
The tty, SDL and web shells default to the Steam Deck (x86_64 linux-gnu, glibc
pinned to 2.38), 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 — the arch is spelled rather than
left null so the CPU model resolves to generic, which is ghostty's
`Config.genericMacOSTarget` workaround. 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 -Dplatform=macos # libpardes.a + pardes.h, and on Darwin a signed pardes.app too
zig build macos-app -Dplatform=macos # the signed bundle on its own
zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over
```
The first line is not "the library only", and that is the part worth stating:
`b.getInstallStep().dependOn(&sign.step)` puts the bundle on the DEFAULT
install step, so on a Darwin host an ordinary `zig build -Dplatform=macos`
assembles `zig-out/pardes.app` and ad-hoc-signs it. build.zig says why in as
many words — the app is "part of an ORDINARY build rather than a verb to
remember". Only the dmg is opt-in, because it is for handing over rather than
for running.
What is gated is that whole branch, and on the TARGET as well as the host:
`builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()` is what decides
whether `fatArchive` runs, and without a Mach-O archive there is nothing for
swiftc to link. Off either, `macos-app` and `macos-dmg` resolve to an explicit
`addFail` — *"pardes.app needs a Darwin host and target; drop -Dtarget= or pass
-Dtarget=native"* — rather than a
bundle that could not have been signed, and the default install stops at the
library. The library and the header build anywhere, which is what the Linux dev
loop below uses.
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, a shader 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 four `addInstallFileWithDir` calls
already do (`install_app_bin`, `install_plist`, `install_scene_kernel`,
`install_icon`).
- **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 scene kernel.** `shaders/crt.ci.metal` is installed verbatim as
`Contents/Resources/crt.ci.metal` and compiled at runtime with
`CIKernel.kernels(withMetalString:)`. The same file is also an anonymous
module import named `effect-source-crt.ci.metal`, added by `build.zig`'s
per-shell module wiring under `if (shell == .macos)`, which is what lets
`EffectCode` link to the exact source the app executes.
Its three entry points are `extern "C" [[stitchable]]`: the runtime compiler
looks for stitchable functions and rejects the WHOLE source with "cannot find
a valid stitchable Metal function in the source" when there are none, which
costs the view its postprocessor and turns every effect silently off. The
`draw-effect` command in the e2e suite exists to catch exactly that.
- **The link.** One swiftc invocation, with the optimize mode following
`-Doptimize` — `-Onone` for Debug, `-Osize` for ReleaseSmall, `-O` otherwise
— so both halves of the app are built the same way:
```sh
swiftc <-O|-Osize|-Onone> -target <arch>-apple-macos13.0 \
-import-objc-header src/macos/pardes.h \
-o <cache>/pardes \
src/macos/Sources/{main,AppDelegate,PardesView,ScenePostprocessor,FileWatcher}.swift \
<cache>/libpardes.a -lc++ \
-framework AppKit -framework CoreText -framework CoreGraphics \
-framework CoreImage -framework Metal
```
The arch is derived from the build target, which defaults to the host — `arm64`
on Apple silicon, `x86_64` on an Intel Mac. What is genuinely missing is a
UNIVERSAL binary: there is no `lipo` step anywhere, so a bundle built on one
arch runs on that arch.
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`.
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.
## 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, and most
of it is no longer even this backend's: `forkShell`, `writeFileBytes` and
`writeFd` are `src/host_io.zig`'s, byte-identical on both systems, and what is
left that differs is `ioctl(TIOCSWINSZ)` (absent from `std.c.T` on darwin),
`/usr/bin/open` against `/usr/bin/xdg-open` (`src/look.zig:33-37`), and libproc
against `/proc`. 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 notches 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
```
With no paths, the harness runs `test/macos-snapshots`. Paths after `--`
select scripts or directories instead. The executable stays in the build cache.
`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`, `command`,
`mouse`, `click`, `wheel`, `resize`, `draw`) plus what only
exists here: `fingers <n> <col> <row>`, `force <col> <row>`,
`rotate <degrees> [gap_ms]`, `rotate_end`,
`drop <path> <col> <row>`, `scroll <rows> <col> <row>`,
`haptic <none|exec|look>`, `nsclick` (the AppKit-event path, as opposed to
`click`'s direct entry-point call), `font <name>`, `font-size <points>`, `zoom`,
`tracks <phase:effect>...`, `draw-effect`, and `clipboard`. The boot script uses
`tracks` followed by `draw-effect` to assert the moving/opening ABI order and
that the runtime Metal owner actually accepted the panel frame (a raw fallback
fails); the font script checks every initial/adopted/zoomed effective point
size without changing its grid goldens.
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.
The harness also owes the core a PRESENTATION, and that is not cosmetic. The
window is borderless and never ordered front, so AppKit runs no display cycle
for it and `draw(_:)` — the only caller of `pardes_frame_presented` — would
never run outside the `draw` command. The core holds pointer gestures inert
while a layout mutation has not reached a backend (`panel_presentation_pending`,
read in `presentedPointer`), which for the app is one frame and for an
unpresenting harness is the rest of the script: the first pane a script opens
would silently kill every later click, drag and Look. So `readFrame` presents
what it just rendered, into a bitmap nobody reads — the app's
`AppDelegate.pump` marks the view and AppKit draws it, and this is the same
debt paid the same way.
The Linux loop proves the new C layout, flag encoding, clock wrap, embedded
kernel source, header syntax, and static library. Compiling Swift, runtime Metal
kernel compilation, and comparing processed pixels remain `macos-e2e` work on
a Darwin host; Linux has neither AppKit nor Apple's Metal runtime.
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 any temporary document a script
opens has a reproducible directory — its six mkstemp characters are masked on
capture. `New` itself no longer makes one: since `c3d0b84` it opens the
in-memory `+New` scratch buffer and `Save` asks for a path.
**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.
Those figures are the direct path with all scene bits off. An enabled scene
adds an offscreen CoreGraphics frame, one Core Image/Metal kernel, and
presentation of its result. That opt-in cost has not been measured on the M2
used for the table and is not folded into the direct-path claim.
## 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 every query is answered
with an EMPTY `lsp_resp` rather than dropped — dropping one leaves the
keystroke that asked (insert-mode Tab after a dot) waiting forever, and dead
for the rest of the session.
- **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.
- **Detached sessions.** `Attach` (`SPC s a`) and `Detach` (`SPC s D`) exist on
every hosted platform, this one included, because `Builtin.enabled` is
`pardes.hosted`. Neither works here. `Detach` emits `Effect.detach`, this
host fills in no `detach`, and `Pardes.perform` therefore reports
`error.NotAttached` on the pane's message row (`src/pardes.zig:6837`).
`Attach` emits `Effect.attach`, which `perform` turns into an `attach_req`
the shell is supposed to consume from OUTSIDE `pump` with `takeAttach` — and
`src/macos.zig` never calls `takeAttach`, so the word does nothing at all and
says nothing either. Wiring it up means a unix-socket frontend loop beside
the AppKit one, which is `src/detached/client.zig`'s job in the tty and SDL
shells; see `docs/detached.md`.
- **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.
- **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 missing universal binary is the deliberate remaining gap.
|