summaryrefslogtreecommitdiff
path: root/docs/macos.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-10 17:23:18 -0300
committerGabriel Schneider <[email protected]>2026-08-11 09:58:59 -0300
commita797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch)
tree45db37a3d190425b3ca2453d46c57d2af27cfc93 /docs/macos.md
parenteb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff)
downloadpardes-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.md321
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.