diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 17:23:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | a797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch) | |
| tree | 45db37a3d190425b3ca2453d46c57d2af27cfc93 /docs/macos.md | |
| parent | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff) | |
| download | pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip | |
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a
relaunch, and builds into something you can hand to someone.
- Pixel attachments. Surface.images was dropped on the floor here, so a PDF
pane showed nothing at all: native_images is now set, pardes_image_s carries
the geometry the core already clipped, and PardesView keeps one CGImage per
(serial, page, revision) so scrolling costs a draw and not a decode. Image
panes get real pixels instead of the petscii fallback.
- Themes take hold live. pardes_tick never advanced the chrome animation, so
every tagline kept the previous theme's colours until the next launch and
the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires
the hand-agreed #121212 and drives the window background and the titlebar
appearance; a theme with no background of its own now gets a transparent
window over an NSVisualEffectView.
- The cell snaps to whole DEVICE pixels rather than whole points. Monaco
advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than
the face was drawn for.
- The dial is one notch per 10 degrees instead of 20, and a release keeps
turning in proportion to how hard it was thrown -- ramping up from zero at
the floor, so a slow twist coasts not a little but not at all.
- A file dropped on the grid is a click plus Look, so it opens beside the pane
it was dropped on. No drop concept was added to the core.
- The titlebar follows the focused pane: proxy icon, filename, and the dirty
dot. File.saved_revision is the watermark that last one needed.
- Config (SPC f c) prints the resolved startup config path.
- build.zig assembles, signs and packages the bundle itself; build-app.sh is
gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and
the icon is Glenda.
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 321 |
1 files changed, 275 insertions, 46 deletions
diff --git a/docs/macos.md b/docs/macos.md index b1666134..7f695000 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -197,15 +197,104 @@ 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 +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° 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. +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 @@ -291,8 +380,8 @@ Identity gained a third sibling. `bin/pardes` and 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. +`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 @@ -335,6 +424,110 @@ 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. @@ -366,7 +559,7 @@ 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 +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. @@ -383,20 +576,43 @@ through `ranlib` first, because ld64 otherwise refuses zig's layout outright 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: 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: +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 -target "$(uname -m)-apple-macos$minver" \ +swiftc -O -target arm64-apple-macos13.0 \ -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++ \ + -o <cache>/pardes \ + src/macos/Sources/{main,AppDelegate,PardesView}.swift \ + <cache>/libpardes.a -lc++ \ -framework AppKit -framework CoreText -framework CoreGraphics ``` @@ -413,19 +629,32 @@ 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. +## 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. ## Testing @@ -477,8 +706,12 @@ zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap `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 +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. @@ -500,7 +733,7 @@ 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`. +**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. @@ -517,12 +750,12 @@ and its phases over 60 frames of a shell pouring out four thousand lines. | **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`. +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 @@ -555,11 +788,6 @@ for exactly that reason. is switched off rather than left to produce an empty second window. Pardes's own columns and panes are the layout, and a second window would need a second core, which the singleton ABI is precisely a decision not to have yet. -- **Native image and PDF placement.** `Surface.images` is ignored. The tty - backend draws these with Kitty graphics and the SDL one with GPU textures; - macOS would be a third path, a `CALayer` or `CGImage` per placement positioned - from the cell rectangle. `pardes_resize` already carries the physical cell - size that path needs. - **A glyph atlas.** Drawing is CoreText per row: runs of cells sharing a face and a colour go out as one `CTFontDrawGlyphs`, ASCII glyph ids are resolved once per face at init and everything else is cached on first sight. That is @@ -578,7 +806,8 @@ for exactly that reason. 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`. +- **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. |
