diff options
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 687 |
1 files changed, 624 insertions, 63 deletions
diff --git a/docs/macos.md b/docs/macos.md index e430f677..7f695000 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,402 @@ 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 10°, 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° was +more than a wrist gives without thinking about it and made the dial feel stuck, +where 10° is still a deliberate turn and 36 steps to a revolution. + +### Momentum + +`pardes_rotate_end` says the fingers came off, and how fast they were moving +when they did decides everything. AppKit gives rotation no momentum phase of +its own — `momentumPhase` belongs to scroll — so the release speed is measured +here, off the monotonic clock, as a smoothed degrees-per-second over the event +stream. + +The curve is the point. Momentum ramps up **from zero** at a 70°/s floor rather +than switching on at it: + +```zig +excess = min(|speed| - rotation_fling_floor, rotation_fling_max) +``` + +A plain threshold would hand out two free notches the instant it was crossed, +and the same gesture a hair quicker jumping twice as far is how a control stops +feeling like a control. So a slow, deliberate turn coasts not a little but not +at all, and the harder it is thrown the further it goes — bounded, by the cap, +at about eleven matches for the hardest flick a trackpad can report. + +Two things stop a fling that was never thrown. A release more than 90 ms after +the last motion event is a hand that *stopped* and then lifted, which is the +most deliberate twist there is and the one a stale velocity sample would fling +hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in +progress, the way a hand catches a dial. + +The coast itself is spent by `pardes_tick`, one fixed 1/60 step per tick with a +0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so +it rides the same 16 ms re-pump a theme transition does and needs no clock of +its own. Fixed rather than measured on purpose: one fling then spends the same +travel every time, which is what lets `rotate.snap` assert it instead of +asserting the machine's timer jitter. + +Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s, +under the floor) and asserts the screen is byte-identical across the release, +then at 12° per 5 ms and asserts it is not. The list it walks is twenty-four +hits rather than three for a reason worth keeping: `n` at the last match has +nowhere to go, so on a short list the hardest possible flick and no flick at +all produce the same screen — a golden that would have passed before momentum +existed. + +## A drop is a click plus Look + +Files dragged onto the grid open beside the pane they were dropped on. + +Finder and the Dock already reached the app through `application(_:open:)`, but +that path cannot say *where* — it opens next to whichever pane happened to have +focus. A drop knows where the hand was, and in acme that is the whole +difference, because `Look` places the document relative to the pane it runs in. + +So the definition is exactly two things the hand could have done itself: the +pointer's cell gets a left press and release, focusing that pane the way a +click there would, and then the ordinary `Look` builtin runs in it. Drop on a +tag and you clicked a tag. **No drop concept was added to the core**, and there +is no case here to special-case. + +`performDragOperation` decodes the pasteboard and the location and then calls +`drop(_:at:)`, one call below the event, for the reason every gesture in this +file is split that way: `NSDraggingInfo` is a protocol with a dozen members and +no public conformer, so a test that had to build one would be testing its own +stub. `test/macos-snapshots/drop.snap` drives that entry point and asserts the +placement rather than the opening — the second file is dropped *inside the pane +the first one opened* and has to land beside it, which is only true if the +click went where the pointer was. + +## The titlebar follows the focused pane + +`pardes_active_path` and `pardes_active_dirty` are read once per pump into +`representedURL`, `title`, and `isDocumentEdited` — the proxy icon you can drag +and Cmd-click for the path, the filename, and the dot in the close button. + +There is no document architecture behind this and deliberately so: no +`NSDocument`, no save panel, no "do you want to save" on close. Save is a +builtin, the pane's tag already says so, and this is the same two facts spelled +where a Mac user looks for them. A terminal or an output buffer is not a +document, so focus landing on one clears the icon and puts the title back to +`pardes` rather than leaving a stale file there. PDFs and images do get an +icon: they are real paths, and a proxy icon is about the file, not about who +may edit it. + +The dirty half needed the one core change in all of this. `File` counted +`revision` but never recorded which edit was last *written*, so no shell could +derive "unsaved" — `File.saved_revision` is that watermark, set by `Save` at +the moment the write is asked for. Nothing in the core renders it and no other +shell reads it; it exists because a windowed host has somewhere to put the +answer. It is marked at ask-time rather than on completion because `save_file` +carries none back, which makes it exactly as honest as the tagline already was. + +## 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 -Dplatform=macos` 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. + +### The cell is snapped to device pixels, not to points + +The grid has to land on whole *device* pixels: the background pass runs with +antialiasing off (touching fills would otherwise seam at every shared edge), so +a fractional column boundary makes the rounding wobble by a pixel from column +to column, and a screen made of tag bars and selections stripes visibly. + +That used to be spelled as whole *points*, which on a Retina display asks for +twice what it needs — half a point already *is* a whole pixel at 2x. The +difference is not academic. Monaco advances 8.4014pt at 14, so ceiling to 9 +spaced every column **7.1% wider than the face was drawn for**: loose, +washed-out text that reads as bad rendering rather than as bad spacing. +`Metrics` now rounds onto `backingScaleFactor`, giving 8.5 — +1.2%. Width +rounds to nearest (a monospace glyph is drawn to fit its own advance, so the +half-pixel either way is slack); height rounds up, because losing a pixel off a +descender is clipping. The ascent is snapped too, so the rules hung off the +baseline are whole-pixel fills rather than one-pixel bars smeared across two. + +The snap is display-dependent, so `viewDidChangeBackingProperties` re-measures: +a scale change moves no bounds and therefore fires no resize. + +What is *not* done, because macOS does not do it: hinting. Apple renders +outlines faithfully and lets stems fall where they fall, which is why Mac text +is softer than a hinted Linux or Windows grid, and why `setShouldSmoothFonts` +is pinned off — smoothing dilates glyphs (measured: +25% lit pixels, +31% ink +mass) and needs to know the colour behind the glyph, which over a transparent +theme it cannot. + +## Pixel attachments: PDFs and images + +`Surface.images` used to be dropped on the floor here, which is why a PDF pane +showed *nothing at all*: with `native_images` false the core assumes a terminal +that cannot draw pixels and degrades a document to counted page turns, and this +host never set it. It does now — this shell draws pixels, which is a fact +rather than a question (the tty backend has to ask the terminal about +kitty-graphics support; the SDL one just says yes, as we do). + +The transport is `pardes_image_s`, walked with `pardes_frame_images` / +`pardes_frame_image_list` after each `pardes_frame`, and it is deliberately +flat: no callbacks, no handles to register or release. Each entry is a +rasterized page or image plus two rectangles — `src` (the crop of the raster) +and `dst` (where it lands), both already clipped to the viewport by the core, +which is what lets a host draw a continuous-scroll page without inventing an +overflow clip. Geometry is in **physical pixels**, the space `pardes_resize`'s +`cell_w`/`cell_h` put the core in; only `cell_x`/`cell_y` are in cells. + +`serial`, `page` and `revision` together are the cache key, and the point of it +is what does *not* move them: panning, zooming to fit and scrolling all reuse +the same raster, so `PardesView` decodes a page once and scrolling costs +nothing but a `CGContext.draw`. The bytes the core lends are only valid until +the next `pardes_frame`, so the `CGImage` owns a copy — which is exactly why +the key has to be good enough that the copy happens when MuPDF re-rasterizes +and never on an ordinary wheel event. Attachments a frame does not place are +evicted, or a session that scrolled a long document would hold every page it +ever showed. + +Two details the picture depends on. The pane BODY is still the clip even though +the geometry is pre-clipped — a page one pixel too tall would otherwise sit on +a tagline. And `isFlipped` gives a y-down CTM while `CGImage` draws +y up, so +each attachment is flipped about its own destination rect rather than about the +view, which keeps the arithmetic in the grid's coordinates. + +Turning this on also changes what an IMAGE pane is here: it was the PETSCII +glyph-art fallback, the same one a terminal without kitty graphics gets, and it +is now the real pixels. + +## Themes, live + +Two bugs lived here, and they were the same bug. + +`ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick` +event. The tty and SDL loops call `core.update(.tick)` on their own clocks; +this host has no loop of its own, so nothing advanced it — `pardes_tick` +drained ptys and reported `themeAnimationActive()` back without ever stepping +the transition. The fade therefore never moved and never ended: every tagline +kept the *previous* theme's colours until the next launch, and the 16 ms +re-pump in `AppDelegate.pump` spun at 60 Hz for the rest of the session. The +pump is the clock, so `pardes_tick` steps it. + +`pardes_theme_bg` is the other half. The window background behind the titlebar +and behind a live resize was a hand-agreed `#121212` in two files; it is now +read from the core, and it carries the theme's *own* background rather than the +chrome's, because document backgrounds switch the instant the theme does while +chrome fades. `window.appearance` follows its luminance, so wearing `acme` no +longer leaves a dark titlebar over a cream grid. + +### Transparent themes + +A theme with `bg = null` — the curated `dark`, and every vendored +`*_transparent` — declares no background of its own. In a terminal that means +"wear whatever the terminal is wearing"; a window has nothing to wear, so +`pardes_theme_bg` answers `PARDES_COLOR_DEFAULT` and the host goes see-through: +`window.isOpaque = false`, a clear background colour, and an +`NSVisualEffectView` (`.underWindowBackground`, `.behindWindow`, `.active`) +behind the grid. `PardesView` stops painting the ground at all — it *clears*, +because AppKit does not blank a non-opaque view — and any cell whose background +is still the default resolves to `bgClear` and is skipped by the run loop. +Reversed cells are not: a reverse puts the text colour in the background, and +text is a real colour that paints. + +The blur is a **sibling** of the grid inside a plain container, never its +parent. Hiding a superview hides its subviews, so a nested backdrop drew a +blank window for every opaque theme the moment it was hidden. + ## Threading One core, touched only from the main thread, plus one pty reader task per pane. @@ -149,66 +551,137 @@ 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 +the app's swiftc link is given, 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 +zig build -Dplatform=macos # leaves a signed zig-out/pardes.app +zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over ``` -runs `src/macos/build-app.sh`, which is the whole second half: +The bundle is assembled by `build.zig` itself, not by a script it shells out +to. An `.app` is a directory with a plist, a binary and an icon in it, and each +of those is one step whose inputs the build graph knows — so the app rebuilds +when a Swift source or the archive moves and is left alone when nothing does. +There is no Xcode project: a hand-written `pbxproj` would be a second build +system to keep in step for what three `addInstallFileWithDir` calls already do. + +- **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed + `src/macos/Info.plist` and writes a stamped copy into the cache. The source + file is never mutated, which is what the old in-place `PlistBuddy` call did. +- **The icon.** The mark is **Glenda**, the Plan 9 rabbit — pardes is an acme, + and acme is Plan 9's. `src/macos/icon.swift` is compiled alone (it is + top-level code: one file, one module) and run with the bundle's `Resources` + as its output directory. She is *drawn*, not traced: four overlapping + ellipses filled as one path under nonzero winding for the silhouette, three + more punched back out in the ground colour for the eyes and nose. A + silhouette rather than an outline because the mark has to survive being + twelve pixels across, where an outlined drawing is a grey smudge with a + lighter grey inside it — the ears are the whole recognition, and they are the + shapes that reach furthest from the mass. Generated rather than committed, so + the palette stays in step with the one `PardesView` draws with (ground + `defaultBG`, Glenda `defaultFG`, the strip above her the tag bar, the block + cursor at the end of it `ansi16[11]`), and there is no binary blob in the tree + to disagree with the app it ships in. +- **The link.** The same swiftc invocation as before, with the optimize mode + following `-Doptimize` so both halves of the app are built the same way: ```sh -swiftc -O -import-objc-header src/macos/pardes.h \ - -o zig-out/pardes.app/Contents/MacOS/pardes \ - src/macos/Sources/*.swift \ - zig-out/lib/libpardes.a -lc++ \ +swiftc -O -target arm64-apple-macos13.0 \ + -import-objc-header src/macos/pardes.h \ + -o <cache>/pardes \ + src/macos/Sources/{main,AppDelegate,PardesView}.swift \ + <cache>/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. + +## Distribution + +`codesign` runs last, over the finished directory — a signature taken before +the icon lands is a signature the icon then breaks — and it is part of the +ordinary build, because an unsigned arm64 bundle does not launch at all. The +default identity is ad-hoc (`-`), which needs no keychain and is enough for the +machine that built it. For a bundle that leaves this machine: + +```sh +zig build macos-dmg -Dplatform=macos -Doptimize=ReleaseFast \ + -Dmacos-identity="Developer ID Application: Your Name (TEAMID)" +``` + +A real identity also gets `--options runtime` and `--timestamp`, which are +notarization's requirements rather than a signature's (`--timestamp` on an +ad-hoc signature is an error, which is why it is conditional). `macos-dmg` +wraps the signed bundle in a compressed read-only UDZO image — the format every +Mac already knows how to open, and the signature survives the copy out of it. + +Notarization itself is one command away and deliberately not wired in, because +it needs credentials and the network: `xcrun notarytool submit zig-out/pardes.dmg +--keychain-profile <profile> --wait`, then `xcrun stapler staple`. -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. +Still not here: an xcframework and `lipo`. Ghostty has both +(`src/build/GhosttyXCFramework.zig`), and they exist for a universal binary and +for letting something other than this app link the core. This ships arm64. -## The Linux dev loop +## Testing -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: +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 ``` -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 +689,80 @@ 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> [gap_ms]`, `rotate_end`, +`drop <path> <col> <row>`, +`scroll`, `haptic <none|exec|look>` and `draw`. The dial's two extras are what +make momentum testable at all: the optional gap is a real sleep before the +event, so a script can say how FAST the dial is being turned, and `rotate_end` +is the release the fling is measured from. 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 -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` while the Zig core followed `-Doptimize`, so the ordinary +build 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 from `-Doptimize` into the swiftc link, 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 +779,35 @@ 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.** Ad-hoc codesigning and a DMG are wired in; a Developer ID + is one `-Dmacos-identity=` away and notarization one `notarytool` call. Still + absent: a universal binary, bundled fonts, localization. The app has an icon, + a plist that says what it opens, a deployment target it actually enforces and + a signature; the arm64-only slice is the deliberate remaining gap. |
