# Native macOS backend Zig owns the core, the ptys, every effect and the worker threads. Swift owns `NSApplication`, the window, input translation, and drawing. They meet at a hand-written C ABI, `src/macos/pardes.h`, built as a static library that the app links; the Swift half is ordinary AppKit that pumps events in and reads one packed cell buffer out. There is exactly one core per process, so the ABI has no handles — the state is a file-scoped singleton and every function names it implicitly, exactly like the browser backend. The research this design came out of is written down separately in `docs/ghostty-macos-notes.md` — how ghostty wires Zig to Swift, what its build plumbing actually requires, and which parts of it are distribution machinery rather than integration. Read that for the alternatives; this file is what was built and why. ## Why not ghostty's split Ghostty was read carefully before this was written, and this backend deliberately inverts its division of labour. Ghostty hands Zig a bare `NSView*` through a tagged platform union (`ghostty_platform_macos_s.nsview`), and Zig then creates a layer, assigns it as the view's layer, sets `wantsLayer`, and installs a display callback — `src/renderer/Metal.zig` in that tree. Swift never renders a glyph; it supplies a rectangle and gets out of the way. Pardes goes the other way because its frame is *already* a cell grid. `pardes.Surface` is `cols × rows` of `Cell`, and CoreText is the native way to draw one: attributed runs, the system font stack, and Apple's own subpixel and color-emoji handling, for free. The alternative is a hand-rolled glyph atlas, and the SDL backend is the measurement of what that costs — roughly three thousand of `src/gui/gui.zig`'s 4,300 lines are a 2048² FreeType-hinted R8 atlas, GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys nothing the grid needs. Blind is the operative word: this backend was scaffolded on a Linux machine. A Metal renderer written there would have been untestable code — compiled at best, never once run — whereas CoreText drawing is a small amount of Swift that only exists on the machine that can run it. The ceiling is accepted and named. Per-cell CoreText drawing is slower than an atlas, and if it fails to hold a full-screen redraw at the target rate the escalation is ghostty's model verbatim: Zig owns a `CAMetalLayer` installed into the view and drives its own frame clock, and the ABI grows a `platform` pointer field carrying the `NSView*` — one field, because the rest of the seam does not change. Nothing here is designed to make that harder. ## Why the ABI mirrors src/web.zig The browser and a Cocoa app are the same host, and the browser proved the shape first. In both, someone else owns the clock and the event loop; events arrive through flat functions that take scalars and borrowed byte ranges; one call renders, and the result is one contiguous array of packed cells the host walks linearly. `pardes_cell_s` is byte-for-byte `WebCell`: seven UTF-8 bytes of grapheme plus a guaranteed zero, tagged `fg`/`bg` words (`0x01000000` default, `0x02000000 | index` for the palette, otherwise `0x00RRGGBB`), an attribute bitfield with the underline style in the high bits, and a `flags` bit meaning "the core never painted this cell" so a host can draw background only and skip the glyph. Two hosts spelling the same encoding is not duplication worth removing; it is the encoding being right. The one real difference is IO. The browser has no ptys, so `src/web.zig` forwards every effect out to JavaScript as a numbered code plus a byte payload, and JavaScript performs it. macOS has `forkpty`, `read` and `write` right there, so effects never cross the boundary at all: `pardes_tick` drains the core's effect queue and performs each one in Zig, the way `drainEffects` in `src/tty/tty.zig` does. That is why the runtime struct is two callbacks and not twelve. ## The seam **Frame.** `pardes_frame` renders and returns the cell count; `pardes_frame_cells` hands back a pointer valid until the next `pardes_frame`, with `pardes_frame_cols`/`_rows` giving its shape. The cursor rides alongside as `pardes_cursor_x`/`_y`, each `-1` when it is hidden, plus `pardes_cursor_bar` asking for a thin insert caret instead of a block. `Surface.images` has no representation here yet; see the checklist. **Input.** `pardes_key` takes a codepoint and the host's already-composed text, because composition is AppKit's job and the core only ever wants finished characters. Keys that carry no text are the four ASCII controls (enter, escape, tab, backspace) and a private-use block starting at `0xF0001` for the arrows and navigation keys — private-use precisely so a functional key can never be confused with a real codepoint arriving as text. `pardes_mouse` speaks acme's vocabulary: three buttons, where 1 selects, 2 executes and 3 looks, with wheel directions as ordinary buttons rather than a separate axis. Ctrl is the only modifier the core consults (a left press with ctrl is goto-definition), but the full mask is passed anyway to keep the signature identical to the web one. `pardes_scroll` carries fractional row and column deltas from a trackpad; the Zig side accumulates each axis separately and synthesizes whole `wheel_up`/`wheel_down`/`wheel_left`/`wheel_right` presses, because the core scrolls on button events and its `touch_scroll` event only records the residual for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and `src/web/app.mjs` both do exactly this already. A real mouse notch skips the smoothing and goes through `pardes_mouse`. `pardes_rotate` is the same shape for a two-finger twist, spent as `n`/`N` — see the trackpad section. `pardes_command` runs one builtin command line through the core's own `command` event, which is the channel a nested pardes speaks; here it is what a menu item is made of and what opens a path from argv or the Dock (`Look `). `pardes_take_haptic` reports the Look or Exec the core just performed and clears it. `pardes_resize` also carries one cell in *physical* pixels, which only the native PDF placement path reads — pass the backing-store size, not points. **Runtime callbacks.** `pardes_runtime_s` is a `userdata` and two functions, copied by value during init so the struct need not outlive the call. `wakeup` means "your state moved, please pump me"; `set_clipboard` fires inside a tick when the yank register changes, with the text borrowed for the duration of the call. There is no `open_url` callback because the core already opens links itself through `/usr/bin/open` (`src/look.zig`), and no file callbacks because it owns the filesystem side too. **Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code, `pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect queue and returns whether anything changed, `pardes_should_quit` reports the Exit builtin or the last pane closing, and `pardes_animating` says a theme transition wants ~60 Hz ticks until it settles — the one thing in an otherwise event-driven frontend that redraws on a clock, handled in `tty.zig` by sleeping the loop and forcing a tick. The ordering contract is the part a header cannot enforce. **Init with the real grid size.** The core defers each shell's greeting until it has seen a resize: `sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0` and the pty has produced its first prompt. Setting `Options.cols`/`rows` alone never bumps that counter, so init must turn its arguments into an actual resize event the way `src/web.zig` does immediately after construction — and the size must be true, because the first `forkpty` takes its winsize from the core's current grid and a shell booted at a placeholder draws its first prompt at the wrong width. **Call `pardes_tick` after every input function.** The input calls only advance the state machine; the writes to the pty, the spawns, the saves all happen in the drain, so an input without a following tick is an input that visibly did nothing. **`wakeup` is the only any-thread entry point in the whole ABI.** Everything else is main-thread only, and the host's `wakeup` must do nothing but hop — one `DispatchQueue.main.async` that calls `pardes_tick` and marks the view dirty. ## The trackpad is the third button acme wants three mouse buttons — 1 selects, 2 executes, 3 looks — and the machine this runs on has a glass rectangle. So the rectangle is taught to speak the vocabulary, and the mapping is the one macOS itself already suggests: | gesture | button | verb | | --- | --- | --- | | one finger | 1 | select | | two fingers | 3 | Look | | three fingers | 2 | Exec | | a deep press | 2 | Exec | | two fingers twisted | — | `n` / `N` | **The finger count decides, not the button stream.** This is the part that only real hardware could teach, and it is worth spelling out because the obvious implementation is wrong. macOS's secondary click is "click or tap with **two or more** fingers", so with that setting on — the default — a *three*-finger click is delivered as `rightMouseDown` exactly like a two-finger one. A view that trusts the stream cannot tell them apart and quietly does Look for both. The trace that caught it, from a real trackpad: ``` pardes: rightMouseDown: resting=2 pardes: rightMouseDown: resting=3 ``` So all three button streams funnel into one `beginClick`, which resolves the button from the fingers first and falls back to the stream only when there are no fingers to count — which is exactly the real-mouse case, where right is Look and the middle button is Exec. The count comes from `event.touches(matching: .touching, in: nil)`, with `nil` rather than the view because that argument filters on touch/view association and an association that fails does not raise, it returns zero fingers — a two-finger Look silently degrading into a select. Belt and braces: the view also keeps a running `restingFingers` from the four `touchesXxx` callbacks, because the touch set hanging off a *mouse* event is an accident of how the click was produced and can come back empty. The mouse event's own set wins when it has anything in it. Whatever it resolves to is then **latched** for the drag and the release: the core tracks a drag keyed by button, and answering a press of 3 with a release of 1 strands it holding a sweep nothing will ever end. A deep press arrives as `pressureChange` reaching stage 2, and only the transition counts — AppKit repeats stage 2 for as long as the finger stays down. By then a press has already gone out, so it is *released* before the middle one is sent. That ordering is not tidiness: a middle press arriving while the core holds a left select-drag is acme's 1-2 chord, which is **Cut**. The release costs a cursor move at the click point, which is what clicking there would have done anyway. Which press gets upgraded is deliberately not restricted to the left one, and that too came from the trace: on a Force Touch trackpad the deep press usually rides a click that already went out on the *right* stream, so gating on a latched left button meant the conversion never fired at all — the log showed `pressure: stage=2 latched=nil` and nothing else. Any in-flight click upgrades; already-Exec is the only case with nothing to do. The view also needs `NSPressureConfiguration(pressureBehavior: .primaryDeepClick)` or stage 2 is the system's business and never arrives — and the user needs "Force Click and haptic feedback" on in System Settings, which nothing in this process can read. Twisting two fingers is a dial, and a dial over a list of search hits is `n`. `pardes_rotate` takes raw degrees and libpardes quantizes them, one search step per 20°, keeping the remainder — the same accumulate-and-spend shape as `pardes_scroll`, in Zig for the same reason: it is then unit-tested on a machine with no trackpad. AppKit reports counterclockwise as positive and the forward step is clockwise, so the sign inverts here and nowhere else. The banked remainder is deliberate hysteresis; a gesture beginning passes 0 to clear it, so the first degree of a new twist cannot inherit a nearly-complete notch from the last one. Measured against a real twist: 95 events, mean 3.3° each, 20° notches, twelve search steps — the granularity is right, and the reason a twist can look like it does nothing is that `n` has nowhere to step until a search is armed. ## When a gesture looks like it did nothing All three verbs are cheap to mistake for broken, because acme's verbs are about the *word under the pointer* and most words resolve to nothing: - **Look** (two fingers) on a filename opens it; on a word that names no file and matches nothing else on screen, it searches, finds where it already is, and the screen does not move. The pulse still fires — the gesture worked. - **Exec** (three fingers) on a builtin name runs it. On ordinary prose it types that word at a shell, which needs a terminal pane to type into. - **`n`/`N`** (twist) steps a results buffer. With no `/pattern`, Grep or Find behind it there is nothing to step. `PARDES_LOG=1` prints every decoded gesture to stderr — the fingers counted, the stream it came in on, the button it resolved to, the pressure stage, the rotation degrees. It exists because which events a trackpad produces is decided by hardware plus four System Settings switches this process cannot read, and because guessing at that from a screenshot cost an afternoon. ## Haptics Every Exec and every Look taps the trackpad. The core arms a one-slot pulse in the ONE dispatcher — `lookAt` and `execute`, the two functions a middle click, a right click, Enter, Tab, a tag chord, `n`/`N` stepping and the `Look`/`Exec` builtins all funnel into — and the host takes it once per pump with `pardes_take_haptic`. Exec gets `.generic`, the definite tap of something done; Look gets `.alignment`, the lighter detent AppKit uses when a dragged guide snaps. A pulse, not a queue: five Execs inside one keystroke are one thing the hand did. Three details are load-bearing. `execute` arms only at `exec_depth == 0`, because `Exec ls` re-enters as `ls` and one Tab is one gesture however many words it unwraps to. `init` and `initFromDump` take the pulse and drop it, so a config file that opens a file with `Look` does not buzz at boot. And the field is `HapticSlot`, `void` on every platform but this one, the way `PdfSlot` is `void` without MuPDF — no other shell reads it, so no other shell carries it. There is no capability check. `NSHapticFeedbackManager` is a silent no-op without a Force Touch trackpad and when the user has feedback switched off, so a check here would only be a second place to be wrong — and it would be wrong the moment an external trackpad is plugged in mid-session. ## libproc, twice Two features on this backend want to know something about a process that is not us, and on Linux both answers live in `/proc`. Darwin's equivalent is libproc, and it answers both. **A pane's cwd** (`look.shellCwd`) is `readlink("/proc//cwd")` there and `proc_pidinfo(PROC_PIDVNODEPATHINFO)` here. The tag shows it and a relative `Look` resolves against it, so it has to follow the shell rather than stay where the pane was spawned. *When* it is read differs from the other two shells, and deliberately. The tty and SDL hosts poll every pane every frame; here the drain has just finished saying exactly which shells produced bytes, and nothing else can have moved one — a `cd` is a command, and a shell that ran a command writes at least its next prompt. So `refreshCwds` reads only for panes flagged by that tick's output and an idle session costs no syscalls at all. `test/macos-snapshots/ cwd.snap` holds the gating to it: the tag must be right after a `cd` and must survive a tick with nothing in it. **A pardes inside a pardes** (`src/nested.zig`) walks the ancestor chain looking for our own executable, and hands the file over rather than stacking a second full-screen UI inside a pane. `readlink("/proc//exe")` becomes `proc_pidpath`, and the `PPid:` line of `/proc//status` becomes `proc_bsdinfo.pbi_ppid`. That struct is hand-written, which is a thing to get silently wrong: a field ordering that puts something else where `ppid` should be still returns a plausible number, so a unit test compares `parentOf(getpid())` against `getppid()`. The socket half needed real portability work rather than a second spelling. Darwin has no `SOCK_CLOEXEC` and no `accept4`, so the flag is set with an `fcntl` after the fact — a race only against a fork on another thread, and both callers are past that. `sun_path` is 104 bytes here against 108 there, so no buffer in the file spells a number any more; they are all sized from the field itself, and an address that does not fit is refused rather than truncated into a path pointing somewhere else. Identity gained a third sibling. `bin/pardes` and `pardes.app/Contents/MacOS/pardes` are one build installed twice and share no directory at all, so the comparison is made at the *install prefix* — the directory holding the `.app`, or the parent of a `bin` — and the bundle's name can never carry the `-os-arch` tail the installed binary does, because `CFBundleExecutable` is a fixed string. `zig build macos-app` then `pardes src/foo.zig` inside the app's own shell opens a pane in the app. ## Fonts and zoom The face is the shell's business and the size is the window's, so the two are reached differently on purpose. `Font ` and the `FontSel` picker are ordinary core builtins, enabled by `pardes.font_picker` — the frontends that draw their own text, which is now the SDL shell and this one. `src/fonts.zig` moved out of `gui/` for that reason. It walks the platform's font directories and reads four small sfnt tables per file to decide whether every glyph has the same advance; no fontconfig and no CoreText, so both shells agree about which faces exist and disagree only about how to rasterize one. macOS needed two things from that walk. Its directories are `/System/Library/Fonts`, that plus `Supplemental`, `/Library/Fonts` and `~/Library/Fonts`; and a third of what is in them — Menlo and Courier included — is a `.ttc` collection rather than a plain face. A collection is a `ttcf` header in front of several sfnt directories, and the table offsets inside one are absolute from the start of the file, so reading face 0 is a matter of finding where its directory begins and changing nothing else. The answer crosses the ABI as a PATH, not a family name: the core already found the file, and asking CoreText to resolve a name would be a second lookup that can disagree. `pardes_font_take` hands it over once, the same take-and-clear shape as the haptic, and the view loads it with `CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a collection, derives bold and italic from it, and re-measures. A file it cannot wear leaves the screen exactly as it was — a terminal that cannot draw has no way back out of itself. Zoom does not touch the core at all. Cmd+, Cmd- and Cmd+0 change the point size, `Metrics` is rebuilt, and the new cell is reported through the same resize path a window drag uses; the core reflows to a different number of columns and knows nothing about points. Cmd+= rather than Cmd++ because AppKit matches the character and `=` is what is under the finger. Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two different kinds of assertion because a snapshot is the core's cell buffer and the core has no font: `font Menlo-Regular` asks the view what it is actually wearing, and the snapshots catch the grid moving when the cell changes size. ## Threading One core, touched only from the main thread, plus one pty reader task per pane. A reader blocks in `read(2)`, appends into that pane's mutex-guarded buffer, and calls `wakeup`; the next tick swaps the buffers under the lock and feeds the bytes in as `output` events. Sixteen panes is the ceiling (`MAX_PANES`), so it is sixteen threads worst case, each of which does nothing but move bytes. Ghostty again is the contrast: it runs a renderer thread *and* an IO thread per surface, each with its own mailbox and wakeup, because it owns the frame clock and must render independently of input. Pardes does not own the clock here — the host does, through `wakeup` and dirty rects — so there is no third thread to synchronize and no mailbox protocol to get wrong. ## Building Two commands, because they are two different machines' problems. ```sh zig build -Dplatform=macos ``` produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h` beside it. `-Dplatform=macos` is the one platform that overrides the repo's default target. Everything else defaults to the Steam Deck (x86_64 linux-gnu, glibc pinned low), and that default is not survivable here: swiftc links this archive, so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the default becomes the host arch at `macos_min_version`, which is the same triple `build-app.sh` gives swiftc, so the two halves of the app cannot disagree about how old a macOS they support. On any other host it stays plain native, which is what keeps the Linux dev loop below runnable. That archive is also *fat*. `b.addLibrary` emits only this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and ghostty-vt's simdutf/highway stay in archives of their own that zig would normally hand to a linker it drives itself. swiftc drives this one and is given a single file, so `fatArchive` in `build.zig` walks `getCompileDependencies` and folds every static archive into one with Apple's `libtool`. This is ghostty's `CombineArchivesStep` minus the non-Darwin half, and it inherits ghostty's two hard-won details: each input is copied and run through `ranlib` first, because ld64 otherwise refuses zig's layout outright (`64-bit mach-o member 'compiler_rt.o' not 8-byte aligned`) and libtool silently *drops* members from it — a 15 MB input came back as 13 MB with half the objects missing, which links almost far enough to look like a source problem. ```sh zig build macos-app -Dplatform=macos # on a Mac; or run the script directly ``` runs `src/macos/build-app.sh`, which is the whole second half: it copies `Contents/Info.plist` and stamps `LSMinimumSystemVersion` from the version build.zig passed it, compiles `src/macos/icon.swift` and runs it to emit `Contents/Resources/pardes.icns`, and links the app: ```sh swiftc -O -target "$(uname -m)-apple-macos$minver" \ -import-objc-header src/macos/pardes.h \ -o zig-out/pardes.app/Contents/MacOS/pardes \ src/macos/Sources/*.swift \ zig-out/lib/libpardes.a -lc++ \ -framework AppKit -framework CoreText -framework CoreGraphics ``` The header goes in through `-import-objc-header` rather than a module map, because the header is read straight out of the source tree and there is nothing to stage; a module map is what an *xcframework* needs, and there isn't one. `-lc++` is there because ghostty-vt pulls in simdutf and highway, which are C++ — the Zig side bundles `compiler_rt` and `ubsan_rt` into the archive (`bundle_compiler_rt` in `build.zig`), so the C++ runtime is the only thing left for this link to supply. `-target` is not optional: without it swiftc uses the host triple, `LC_BUILD_VERSION` records whatever macOS built the thing, and dyld refuses to launch it on anything older — the plist's `LSMinimumSystemVersion` is a claim, not the enforcement. The deployment version is spelled once, as `macos_min_version` in `build.zig`, and reaches the `-target`, the plist and the library's own target from there. The icon is generated rather than committed: no binary blob in the tree, and its palette stays in step with the one `PardesView` draws with, because both read the same constants. No Xcode project, no xcframework, no `lipo`, no codesigning — ghostty has all four (`macos/Ghostty.xcodeproj`, `src/build/GhosttyXCFramework.zig`, the entitlements files), and every one of them exists for *distribution*: a universal binary for two architectures, a framework other targets can consume, a signature and notarization for Gatekeeper. A dev build that runs on the machine that compiled it needs none of it. They are the named upgrade path, in that order: `lipo` when a second architecture matters, an xcframework when something other than this app links the core, codesigning and entitlements the day it is handed to someone else. ## Testing Three layers, and each one exists because the layer above it cannot reach where it goes. **The Linux dev loop.** The Zig half of this backend is plain POSIX. `forkpty`, `read`, `write`, `ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty backend by a string constant. So `-Dplatform=macos` compiles on a Linux host, and its tests run there: ```sh zig build unit-test -Dplatform=macos ``` That covers the ABI's Zig side, the effect drain, and the two quantizers the trackpad depends on — `takeScrollTicks` and `takeRotationNotches` are pure functions precisely so that "how many search steps is a 180° twist" is answerable on a machine with no trackpad in it. The header is kept honest with ghostty's trick. `build.zig` runs `translate-C` over `src/macos/pardes.h` into the unit-test build, and `src/macos.zig` asserts every constant and every struct layout against the Zig side — the color tags, the attribute bits, the key codepoints, the mouse, modifier and haptic ordinals, `@sizeOf(pardes_cell_s)` and each field offset. A hand-written header is a second source of truth, and the only defensible way to keep one is a test that fails the moment the two disagree. A second test compares every exported function's arity and scalar widths against the header's declaration. It is not a type equality — `translate-C` spells pointers `[*c]` and mints its own struct types, so nothing would ever match exactly — but arity and width are what actually break. It earned its place immediately: `pardes_scroll` grew a cell coordinate after the Swift view had already been written against the one-argument form, and nothing but a human reading both files would have caught it. It earned it a second time when the same function grew a horizontal axis. **The offscreen AppKit suite.** Everything above stops at the ABI. This one drives the real `PardesView` in a real (borderless, offscreen, activation- prohibited) `NSWindow`, over a real core with real ptys: ```sh zig build macos-e2e -Dplatform=macos # run it zig build macos-e2e -Dplatform=macos -- --update # regenerate the goldens zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap ``` `test/macos_e2e.swift` links the same Swift sources the app does, minus `main.swift`, into a second binary — test scaffolding does not ship inside the product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`) plus what only exists here: `fingers `, `force`, `rotate `, `scroll`, `haptic ` and `draw`. Output is byte-identical in shape to `test/snapshot.zig`'s, so a grid captured through CoreText and one captured through a pty can be read side by side. This is the layer that can assert the trackpad features, and the reason it can is that `NSTouch`, pressure stages and rotation have **no public constructors** — a test can never synthesize the events. So the view is built with the decision one call below the event: every override decodes and then calls `press`/`release`/`click`/`rotate`/`typeKey`, and `Trackpad.button(fingers:)` is pure policy with no `NSEvent` in it. The scripts drive those, which is everything except the two lines that read the properties off the event. `haptic` reads `pardes_take_haptic` back, which is how a pulse is asserted on a machine that cannot feel one; `draw` renders the view with `cacheDisplay` and fails if every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap` reads the core's cell buffer and would be perfectly happy with a `draw` that returned on its first line. Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and `TMPDIR` inside the per-script world so that `New`'s document has a reproducible directory — its six mkstemp characters are masked on capture. **The app itself.** `zig build macos-app -Dplatform=macos && open zig-out/pardes.app`. Some things only a hand can test: which System Settings checkbox is on, what a deep press feels like, whether the haptic lands with the click or after it. ## Performance Measured on an M2, one window at 190x56 (1710x984 points), timing `draw(_:)` and its phases over 60 frames of a shell pouring out four thousand lines. | | Debug core | ReleaseFast core | |---|---|---| | `pardes_frame` | 4119 us | 413 us | | background pass | 160 us | 187 us | | glyph pass | 226 us | 264 us | | **whole `draw`** | **4516 us** | **879 us** | The finding is the first row, and it is not about drawing at all. `swiftc` was hardcoded to `-O` in `build-app.sh` while the Zig core followed `-Doptimize`, so the ordinary `zig build macos-app` shipped an optimized shell wrapped around a Debug core — and that reads as "the mac backend is slow" rather than "you built Debug". The mode now travels as the script's third argument, both halves are compiled the same way, and a Debug bundle says so on the way out. Build one you intend to *use* with `-Doptimize=ReleaseFast`. What is left is honest: 0.88 ms against a 16 ms frame, and the Swift half is 0.45 ms of it. Nothing here is a CoreText problem yet. The two things that would be worth doing before reaching for Metal, if a bigger window ever makes this matter, are both in `pardes_frame` rather than in the view — it re-renders every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect` for exactly that reason. ## Not implemented - **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and content taken *before* it starts, as `LspJob` in `tty.zig` does; the core keeps editing while a query is in flight. Until then language queries are dropped and nothing waits for an answer. - **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a job, a worker to run the command, and a `pipe_resp` event back. Same shape as `lsp`, one more response type. - **The `watch` effect.** `watchPane` is inotify and returns silently off Linux (`ponytail:` at `src/tty/tty.zig:1035`). macOS wants the FSEvents half of `std.Build.Watch`, which the standard library already has as `Build/Watch/FsEvents.zig`. Without it the core simply never receives `file_changed`, a state it tolerates because the browser has no filesystem either. - **IME and marked text.** Only finished characters reach `pardes_key`, so a dead key composes nothing and Option is Alt rather than a compose modifier. Real composition means implementing `NSTextInputClient` *and* giving the core a way to render an underlined preedit run, which no backend has yet — the second half is why this is not just an AppKit protocol away. - **Tabs and splits at the window level.** One window, one grid; window tabbing is switched off rather than left to produce an empty second window. Pardes's own columns and panes are the layout, and a second window would need a second core, which the singleton ABI is precisely a decision not to have yet. - **Native image and PDF placement.** `Surface.images` is ignored. The tty backend draws these with Kitty graphics and the SDL one with GPU textures; macOS would be a third path, a `CALayer` or `CGImage` per placement positioned from the cell rectangle. `pardes_resize` already carries the physical cell size that path needs. - **A glyph atlas.** Drawing is CoreText per row: runs of cells sharing a face and a colour go out as one `CTFontDrawGlyphs`, ASCII glyph ids are resolved once per face at init and everything else is cached on first sight. That is enough for a grid this size, and it is still a cmap-and-rasterizer path where the SDL shell has a 2048² atlas. It has now been profiled rather than guessed at (see Performance): the glyph pass is 264 us of an 879 us frame, which is not where the time is, so the escalation is still not warranted. When it is, it is ghostty's: a `CAMetalLayer` installed into the view and driven from Zig, with the ABI growing one `platform` pointer field. - **A Tahoe icon asset.** `src/macos/icon.swift` emits a full-colour `.icns`, every one of the ten sizes, and that is the correct and only format at a 13.0 deployment target. macOS 26's Dock defaults to the `ClearAutomatic` icon style, which desaturates any icon that does not ship the new appearance variants, so ours renders there in grey while apps built with Icon Composer keep their colour. Matching them means an `Assets.car` produced by an Xcode 26 tool, which is the first thing in this backend that would actually require Xcode — hence not done. The file itself is verifiably correct: `iconutil -c iconset` round-trips all ten, and the tag bar is `#3465A4` at every size. - **Distribution.** No codesigning, no notarization, no universal binary, no bundled fonts, no localization. The app has an icon, a plist that says what it opens, and a deployment target it actually enforces; everything past that is Gatekeeper's business and starts with `lipo`.