diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /docs/macos.md | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 422 |
1 files changed, 377 insertions, 45 deletions
diff --git a/docs/macos.md b/docs/macos.md index e430f677..b1666134 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -85,14 +85,20 @@ 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 +`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_resize` also carries one cell -in *physical* pixels, which only the native PDF placement path reads — pass the -backing-store size, not points. +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 two functions, copied by value during init so the struct need not outlive the call. `wakeup` @@ -126,6 +132,209 @@ 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/<pid>/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/<pid>/exe")` becomes +`proc_pidpath`, and the `PPid:` line of `/proc/<pid>/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 <name>` 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. @@ -149,32 +358,64 @@ 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. +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: +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 -import-objc-header src/macos/pardes.h \ +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 ``` -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. +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 @@ -186,29 +427,32 @@ 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 +## Testing + +Three layers, and each one exists because the layer above it cannot reach where +it goes. -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: +**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. +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 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. +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 @@ -216,7 +460,76 @@ 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. +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 <n>`, `force`, `rotate <degrees>`, `scroll`, `haptic +<none|exec|look>` 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 @@ -233,20 +546,39 @@ reading both files would have caught it. `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 +- **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. -- **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. +- **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`. |
