summaryrefslogtreecommitdiff
path: root/docs/macos.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/macos.md')
-rw-r--r--docs/macos.md422
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`.