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
|
# 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² stb_truetype 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 a fractional row delta from a trackpad; the Zig side
accumulates it and synthesizes whole `wheel_up`/`wheel_down` presses, because
the core scrolls on button events and its `touch_scroll` event only records the
residual for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and
`src/web/app.mjs` both do exactly this already. A real mouse notch skips the
smoothing and goes through `pardes_mouse`. `pardes_resize` also carries one cell
in *physical* pixels, which only the native PDF placement path reads — pass the
backing-store size, not points.
**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.
## 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. This is ordinary Zig cross-compilation and runs anywhere, which is
the whole point of the next section.
```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:
```sh
swiftc -O -import-objc-header src/macos/pardes.h \
-o zig-out/pardes.app/Contents/MacOS/pardes \
src/macos/Sources/*.swift \
zig-out/lib/libpardes.a -lc++ \
-framework AppKit -framework CoreText -framework CoreGraphics
```
plus copying `Contents/Info.plist`, and the bundle is done. The header goes in
through `-import-objc-header` rather than a module map, because the header is
read straight out of the source tree and there is nothing to stage; a module map
is what an *xcframework* needs, and there isn't one. `-lc++` is there because
ghostty-vt pulls in simdutf and highway, which are C++ — the Zig side bundles
`compiler_rt` and `ubsan_rt` into the archive (`bundle_compiler_rt` in
`build.zig`), so the C++ runtime is the only thing left for this link to supply.
Without that bundling the swiftc link ends in undefined symbols, which is the
first thing ghostty's `GhosttyLib.initStatic` does too.
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.
## The Linux dev loop
The Zig half of this backend is plain POSIX. `forkpty`, `read`, `write`,
`ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty backend by a string
constant. So `-Dplatform=macos` compiles on a Linux host, and its tests run
there:
```sh
zig build unit-test -Dplatform=macos
```
Only the Swift app needs a Mac. That is what makes this scaffold verifiable
rather than dead code: the ABI's Zig side, the pty plumbing and the effect drain
are all exercised on the machine they were written on, and the part that cannot
be is small, visible, and made of AppKit calls.
The header is kept honest with ghostty's trick. `build.zig` runs `translate-C`
over `src/macos/pardes.h` into the unit-test build, and `src/macos.zig`
asserts every constant and every struct layout against the Zig side — the color
tags, the attribute bits, the key codepoints, the mouse and modifier ordinals,
`@sizeOf(pardes_cell_s)` and each field offset. A hand-written header is a
second source of truth, and the only defensible way to keep one is a test that
fails the moment the two disagree.
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.
## 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`. Real
composition means implementing `NSTextInputClient` and giving the core a way
to render an underlined preedit run, which no backend has yet.
- **Tabs and splits at the window level.** One window, one grid. Pardes's own
columns and panes are the layout, and a second window would need a second
core, which the singleton ABI is precisely a decision not to have yet.
- **Native image and PDF placement.** `Surface.images` is ignored. The tty
backend draws these with Kitty graphics and the SDL one with GPU textures;
macOS would be a third path, a `CALayer` or `CGImage` per placement positioned
from the cell rectangle. `pardes_resize` already carries the physical cell
size that path needs.
- **App-bundle resources.** The plist is the minimum that makes a windowed app:
no icon, no bundled fonts, no asset catalog, no localization.
- **argv and file-open handling.** No positional path, no `-l` dump load, and no
`application:openFile:`. The first pane spawns with an empty cwd, so the shell
inherits the process's — which for a bundle launched from Finder is `/`, and
is the first thing worth fixing here.
|