diff options
Diffstat (limited to 'docs/macos.md')
| -rw-r--r-- | docs/macos.md | 128 |
1 files changed, 100 insertions, 28 deletions
diff --git a/docs/macos.md b/docs/macos.md index ff19ff93..12e598b5 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -28,7 +28,7 @@ Pardes goes the other way because its frame is *already* a cell grid. draw one: attributed runs, the system font stack, and Apple's own subpixel and color-emoji handling, for free. The alternative is a hand-rolled glyph atlas, and the SDL backend is the measurement of what that costs — roughly three -thousand of `src/gui/gui.zig`'s 4,300 lines are a 2048² FreeType-hinted R8 atlas, +thousand of `src/gui/gui.zig`'s 4,700 lines are a 2048² FreeType-hinted R8 atlas, GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys nothing the grid needs. @@ -74,8 +74,9 @@ reach for itself. `pardes_frame_cells` hands back a pointer valid until the next `pardes_frame`, with `pardes_frame_cols`/`_rows` giving its shape. The cursor rides alongside as `pardes_cursor_x`/`_y`, each `-1` when it is hidden, plus `pardes_cursor_bar` -asking for a thin insert caret instead of a block. `Surface.images` has no -representation here yet; see the checklist. +asking for a thin insert caret instead of a block. `Surface.images` crosses +separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel +attachments" below. **Input.** `pardes_key` takes a codepoint and the host's already-composed text, because composition is AppKit's job and the core only ever wants finished @@ -106,10 +107,12 @@ native PDF placement path reads — pass the backing-store size, not points. copied by value during init so the struct need not outlive the call. `wakeup` means "your state moved, please pump me". `set_clipboard` hands over text to put on the pasteboard, borrowed for the duration of the call; it fires inside a tick -for the five `SPC` clipboard words and for the tag's own `y` chord, and for +for `SPC y` / `SPC Y` and for the tag's own `y` chord, and for nothing else — an ordinary `y` or `d` writes the core's register and never reaches here, which is what stopped deleting a character from clobbering the -desktop's clipboard. `read_clipboard` is that direction reversed: "give me the +desktop's clipboard. The other three clipboard words — `SPC p`, `SPC P`, +`SPC R` — go the OTHER way and raise `read_clipboard` instead. +`read_clipboard` is that direction reversed: "give me the pasteboard", with no payload either way, because the host answers by calling `pardes_paste` — and NSPasteboard being synchronous, that usually happens inside the callback itself, before it returns. Both are main thread, inside @@ -227,8 +230,8 @@ its own — `momentumPhase` belongs to scroll — so the release speed is measur 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: +The curve is the point. Momentum is scaled off the EXCESS over a 70°/s floor +rather than being a flat amount the moment the floor is crossed: ```zig excess = min(|speed| - rotation_fling_floor, rotation_fling_max) @@ -236,8 +239,11 @@ 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, +feeling like a control. There is a second floor under that one: an excess below +`rotation_fling_stop` (18) coasts at zero, so 70–88°/s is a dead band and the +smallest coast that happens at all is 18°/s — under a fifth of one notch, which +is why a slow deliberate turn still spends no notches at all. 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 @@ -293,7 +299,9 @@ click went where the pointer was. 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 +`NSDocument`, no save panel, no "do you want to save" on close. (One piece of +document chrome does exist, and it is the harmless one: File ▸ Open… runs a +real `NSOpenPanel` and hands what you pick to Look.) 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 @@ -309,6 +317,41 @@ 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. +## The menu bar, and the chords the core never sees + +A Mac app without a menu bar is a Mac app that is wrong, and most of what a +menu bar wants to say pardes already has a word for. So the menu is mostly a +second spelling of builtins — `AppDelegate` calls `run("New")`, `run("Save")`, +`run("Help")`, `run("Tutor")` — and the items with no builtin behind them are +left out rather than stubbed. + +| menu | item | chord | what it does | +|---|---|---|---| +| pardes | About pardes | — | AppKit's panel | +| | Hide pardes / Hide Others / Show All | ⌘H / ⌥⌘H / — | AppKit | +| | Quit pardes | ⌘Q | AppKit | +| File | Open… | ⌘O | a real `NSOpenPanel`, then Look on what you picked | +| | New | ⌘N | the `New` builtin | +| | Save | ⌘S | the `Save` builtin | +| | Close Window | ⌘W | AppKit; the app terminates after the last one | +| Edit | Paste | ⌘V | the view's own paste path, not a second one | +| View | Zoom In / Zoom Out / Actual Size | ⌘= / ⌘- / ⌘0 | point size, host-side | +| Window | Minimize / Zoom | ⌘M / — | AppKit | +| Help | pardes Help | ⌘? | the `Help` builtin | +| | Tutorial | — | the `Tutor` builtin | + +Edit holds Paste and nothing else. Copy, Cut, Undo and Select All are +deliberately absent: pardes's own words for those are `SPC y`, the 1-2 chord, +`u` and `%`, and a menu item that ran a different thing under the same name +would be worse than no item. + +The consequence is worth stating plainly, because it is invisible from the +core's side: those chords are now **the menu's**, and the core can never see +them. Nor can it see any other Command chord — the ABI's modifier mask carries +ctrl, alt and shift and has no super bit, so `PardesView` swallows Cmd rather +than sending a key the core would misread as unmodified. Cmd is the host's +layer here; everything pardes binds lives on the other four. + ## When a gesture looks like it did nothing All three verbs are cheap to mistake for broken, because acme's verbs are about @@ -325,7 +368,8 @@ the *word under the pointer* and most words resolve to nothing: with no results buffer anywhere still steps the pane in front of you. A screen with no filename on it is what has nothing to step. -`PARDES_LOG=1` prints every decoded gesture to stderr — the fingers counted, the +Setting `PARDES_LOG` at all — the gate tests presence, not value, so even +`PARDES_LOG=0` counts — 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 @@ -430,7 +474,7 @@ 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, +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 @@ -547,11 +591,17 @@ 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. -A reader blocks in `read(2)`, appends into that pane's mutex-guarded buffer, and -calls `wakeup`; the next tick swaps the buffers under the lock and feeds the -bytes in as `output` events. Sixteen panes is the ceiling (`MAX_PANES`), so it -is sixteen threads worst case, each of which does nothing but move bytes. +One core, touched only from the main thread, plus one pty reader task per pane +and one socket listener for the nested-pardes protocol. A reader blocks in +`read(2)` and pushes into ONE process-wide `Inbox` — a bounded ring of tagged +messages, not a buffer per pane — then calls `wakeup`; the next tick drains the +ring wholesale and feeds the bytes in as `output` events. The ring is lossy +under sustained backpressure: an `output` message can be dropped, and `eof` and +`command` will evict a queued `output` to get in, because losing a byte of +scrollback is survivable and losing the end of a pane is not. Sixteen panes is +the ceiling (`MAX_PANES`), and the readers run on a default-sized +`std.Io.Threaded` pool, so the thread count is the pool's rather than one per +pane. Ghostty again is the contrast: it runs a renderer thread *and* an IO thread per surface, each with its own mailbox and wakeup, because it owns the frame clock @@ -593,10 +643,17 @@ 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 -Dplatform=macos # leaves a signed zig-out/pardes.app +zig build -Dplatform=macos # zig-out/lib/libpardes.a + include/pardes.h +zig build macos-app -Dplatform=macos # ...and a signed zig-out/pardes.app zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over ``` +All three need a Darwin host: the app branch is gated on +`builtin.os.tag.isDarwin()`, and off Darwin `macos-app` and `macos-dmg` resolve +to an explicit build failure rather than a bundle that could not have been +signed. A plain `zig build -Dplatform=macos` still gives you the library and +the header anywhere, which is what the Linux dev loop below uses. + 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 @@ -625,7 +682,7 @@ system to keep in step for what three `addInstallFileWithDir` calls already do. following `-Doptimize` so both halves of the app are built the same way: ```sh -swiftc -O -target arm64-apple-macos13.0 \ +swiftc -O -target <host-arch>-apple-macos13.0 \ -import-objc-header src/macos/pardes.h \ -o <cache>/pardes \ src/macos/Sources/{main,AppDelegate,PardesView}.swift \ @@ -633,6 +690,11 @@ swiftc -O -target arm64-apple-macos13.0 \ -framework AppKit -framework CoreText -framework CoreGraphics ``` +The arch is derived from the build target, which defaults to the host — `arm64` +on Apple silicon, `x86_64` on an Intel Mac. What is genuinely missing is a +UNIVERSAL binary: there is no `lipo` step anywhere, so a bundle built on one +arch runs on that arch. + 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. @@ -716,16 +778,24 @@ 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 ``` +The step always hands the harness the whole `test/macos-snapshots` directory, +so naming one script after `--` runs it IN ADDITION to the suite rather than +instead of it. To run a single script, invoke +`zig-out/bin/pardes-macos-e2e test/macos-snapshots/rotate.snap` directly. + `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 +product. Scripts are `test/macos-snapshots/*.snap` (seven of them: boot, cwd, +drop, font, keys, rotate, trackpad) and speak the tty suite's +vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`, +`mouse`, `click`, `wheel`, `resize`, `draw`) 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 +`drop <path> <col> <row>`, `scroll <rows> <col> <row>`, +`haptic <none|exec|look>`, `nsclick` (the AppKit-event path, as opposed to +`click`'s direct entry-point call), `font <name>`, `zoom` and `clipboard`. +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 @@ -785,13 +855,15 @@ for exactly that reason. - **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and content taken *before* it starts, as `LspJob` in `tty.zig` does; the core - keeps editing while a query is in flight. Until then language queries are - dropped and nothing waits for an answer. + keeps editing while a query is in flight. Until then every query is answered + with an EMPTY `lsp_resp` rather than dropped — dropping one leaves the + keystroke that asked (insert-mode Tab after a dot) waiting forever, and dead + for the rest of the session. - **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a job, a worker to run the command, and a `pipe_resp` event back. Same shape as `lsp`, one more response type. - **The `watch` effect.** `watchPane` is inotify and returns silently off Linux - (`ponytail:` at `src/tty/tty.zig:1035`). macOS wants the FSEvents half of + (`ponytail:` at `src/tty/tty.zig:1183`). macOS wants the FSEvents half of `std.Build.Watch`, which the standard library already has as `Build/Watch/FsEvents.zig`. Without it the core simply never receives `file_changed`, a state it tolerates because the browser has no filesystem @@ -827,4 +899,4 @@ for exactly that reason. 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. + a signature; the missing universal binary is the deliberate remaining gap. |
