diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-08 10:44:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | c3c8bbd8d8add99088c774c54bc1acf1e39ec895 (patch) | |
| tree | a602212f59134909532093765a80c87f219c6c4b /docs/macos.md | |
| parent | 8aafc3fa24c7475a07259eb06cd5d217f510df98 (diff) | |
| download | pardes-c3c8bbd8d8add99088c774c54bc1acf1e39ec895.tar.gz pardes-c3c8bbd8d8add99088c774c54bc1acf1e39ec895.zip | |
a native macOS backend: libpardes plus an AppKit shell
Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the
core, the ptys, every effect and the worker threads; Swift owns NSApplication,
the window, input translation, and drawing the cell grid with CoreText. They
meet at a hand-written C ABI in src/macos/pardes.h, built as a static library
the app links.
The ABI is src/web.zig's boundary with the wasm removed, because both hosts are
the same animal: someone else owns the clock, feeds events in through flat
functions, and reads one packed cell buffer out. The browser proved the shape.
The one divergence is that the browser has no processes and forwards every
effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs
them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link,
set_clipboard. lsp, pipe and watch are answered with nothing and marked; the
core already tolerates that, since the browser answers none of them either.
This deliberately inverts ghostty's split, which was studied first and is
written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*,
installs its own CALayer and owns the frame clock; Swift never renders. Pardes
does the opposite because its frame is already a cell grid and CoreText draws
one natively — the alternative is a second hand-rolled glyph atlas, which is
what most of gui.zig's 4,300 lines already are. It would also have been written
blind: the Swift half cannot be compiled here.
What makes the scaffold verifiable rather than dead code is that the Zig half is
ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick,
build.zig translate-C's the header into the test build and src/macos.zig asserts
every constant, struct layout, and exported function's arity and widths against
it. That guard earned its place immediately: pardes_scroll grew a cell
coordinate after the Swift view had been written against the older form.
Skipped, and named as the upgrade path in docs/macos.md: the Xcode project,
xcframework, lipo and codesigning ghostty needs. All four exist for
distribution; a dev build is a swiftc invocation and a directory with a plist.
The Swift app is a scaffold and says so — every uncertain API spelling carries
an UNVERIFIED marker, and no part of it has been compiled.
tty is unaffected: 75/75 snapshot scripts and both unit suites pass.
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/docs/macos.md b/docs/macos.md new file mode 100644 index 00000000..35fba5f3 --- /dev/null +++ b/docs/macos.md @@ -0,0 +1,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. |
