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