diff options
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. |
