From 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 18:58:37 -0300 Subject: An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed. --- docs/macos.md | 193 +++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 136 insertions(+), 57 deletions(-) (limited to 'docs/macos.md') diff --git a/docs/macos.md b/docs/macos.md index 940d5a58..2b50e6bc 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -27,10 +27,10 @@ Pardes goes the other way because its frame is *already* a cell grid. `pardes.Surface` is `cols × rows` of `Cell`, and CoreText is the native way to 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,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. +and the SDL backend is the measurement of what that costs — a large part of +`src/gui/gui.zig`'s 6,348 lines is a 2048² FreeType-hinted R8 atlas (`atlas_w` +and `atlas_h`, `src/gui/gui.zig:140`), GPU pipelines, transfer buffers and +shaders. Writing a second one, blind, buys nothing the grid needs. Blind is the operative word: this backend was scaffolded on a Linux machine. A Metal renderer written there would have been untestable code — compiled at best, @@ -65,13 +65,28 @@ removing; it is the encoding being right. The one real difference is IO. The browser has no ptys, so `src/web.zig` forwards every effect out to JavaScript as a numbered code plus a byte payload, -and JavaScript performs it. macOS has `forkpty`, `read` and `write` right there, -so almost no effect crosses the boundary at all: `pardes_tick` drains the -core's effect queue and performs each one in Zig, the way `drainEffects` in -`src/tty/tty.zig` does. That is why the runtime struct is three callbacks and -not twelve — and why two of the three are the clipboard: the pasteboard is -AppKit's, in both directions, and is the one piece of IO the Zig side cannot -reach for itself. +and JavaScript performs it. macOS has `forkpty`, `read` and `write` right +there, so almost no effect crosses the boundary at all: `pardes_tick` runs +`while (core.nextEffect()) |e| core.perform(e)` (`src/macos.zig:1154`) and each +effect lands on this host's own `Host.VTable`, of whose twenty-one methods +fourteen are filled in (`src/macos.zig:1761`). + +The machine-local half of those methods is no longer this file's. `forkShell`, +`writeFileBytes` and `writeFd` live in `src/host_io.zig` and are shared with +the tty shell, the SDL shell and the detached daemon; `src/macos.zig` calls +them (`:1807`, `:1823`, `:1848`, `:1863`) and its own copies, together with the +four `extern "c"` declarations they needed (`forkpty`, `execv`, `chdir`, +`_exit`), are gone. Two `extern "c"` declarations remain here: `setenv`, and +`pardes_host_watch_file`, which FileWatcher.swift satisfies +(`src/macos.zig:40`, `:45`). The bug all four hosts' private `forkShell` +copies had in common is the argument for the shared file existing: none set +`FD_CLOEXEC` on +the pty master, so a program in one pane could read and write another pane's +terminal and closing a master did not reliably hang its shell up. + +That is why the runtime struct is three callbacks rather than a second vtable — +and why two of the three are the clipboard: the pasteboard is AppKit's, in both +directions, and is the one piece of IO the Zig side cannot reach for itself. ## The seam @@ -426,8 +441,11 @@ and it answers both. `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 +*When* it is read differs from the other three shells, and deliberately. The +tty, SDL and detached-daemon hosts poll every pane every frame — inline in the +tty loop's frame path (`src/tty/tty.zig:1228-1231`), `pollCwds` in the SDL +shell (`src/gui/gui.zig:4048`) and `pollFrame` in the daemon +(`src/detached/server.zig:889`); 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 @@ -446,19 +464,25 @@ 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 +`fcntl` after the fact — a race only against a fork on another thread, and +every caller is past that (`src/nested.zig:81-93` names all three). +`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. +Identity gained a third sibling. `bin/pardes`, `bin/pardes-gui` and +`pardes.app/Contents/MacOS/pardes` are three installed frontends of one +program, so `samePardesExecutable` (`src/nested.zig:142`) compares BASENAMES +and ignores the paths entirely — the GUI may be system-wide while the tty +frontend sits in the user's own bin directory. `familyTail` strips a leading +`pardes-gui` or `pardes` and requires what is left to be either empty or a +valid `-os-arch` pair, which is what keeps `pardes-snap` and `pardes-perf` out +of the family; `sameFamily` then accepts two names whose tails agree, or either +of which has no tail at all. The bundle's copy always has none, because +`CFBundleExecutable` is a fixed string: the bundled `pardes-macos-aarch64` is +called `pardes` and nothing in the name records what it was. So `pardes +src/foo.zig` inside the app's own shell opens a pane in the app. ## Fonts and zoom @@ -717,24 +741,39 @@ synchronize and no mailbox protocol to get wrong. ## Building -Two commands, because they are two different machines' problems. +One command builds everything this shell has. The two extra steps below exist +only for handing a bundle to somebody else. ```sh zig build -Dplatform=macos ``` produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h` -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. +beside it — and on a Darwin host a signed `zig-out/pardes.app` as well, which +the next section is about. + +`zig-out` and not `~/.local`, and that now needs saying. A bare `zig build` +redirects the install prefix to `$HOME/.local` and prints that it has, but only +behind the guard `also_gui and prefixIsUntouched(b)`: `also_gui` is +`requested_platform == null`, and `prefixIsUntouched` refuses when a `DESTDIR`, +a `--prefix` or a `--prefix-*dir` has already chosen somewhere. `-Dplatform=macos` +names a shell, so neither condition holds and every path in this document is +relative to `zig-out` unless you pass `--prefix` yourself. The `/dev` +directory the benchmark binaries install into likewise never appears in this +backend; nothing here is a dev binary. + +`-Dplatform=macos` is one of the two platforms that override the repo's default +target; `esp32p4` is the other, and pins its own riscv32-freestanding query. +The tty, SDL and web shells default to the Steam Deck (x86_64 linux-gnu, glibc +pinned to 2.38), 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 — the arch is spelled rather than +left null so the CPU model resolves to generic, which is ghostty's +`Config.genericMacOSTarget` workaround. 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 @@ -749,23 +788,37 @@ 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 # 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 +zig build -Dplatform=macos # libpardes.a + pardes.h, and on Darwin a signed pardes.app too +zig build macos-app -Dplatform=macos # the signed bundle on its own +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 first line is not "the library only", and that is the part worth stating: +`b.getInstallStep().dependOn(&sign.step)` puts the bundle on the DEFAULT +install step, so on a Darwin host an ordinary `zig build -Dplatform=macos` +assembles `zig-out/pardes.app` and ad-hoc-signs it. build.zig says why in as +many words — the app is "part of an ORDINARY build rather than a verb to +remember". Only the dmg is opt-in, because it is for handing over rather than +for running. + +What is gated is that whole branch, and on the TARGET as well as the host: +`builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()` is what decides +whether `fatArchive` runs, and without a Mach-O archive there is nothing for +swiftc to link. Off either, `macos-app` and `macos-dmg` resolve to an explicit +`addFail` — *"pardes.app needs a Darwin host and target; drop -Dtarget= or pass +-Dtarget=native"* — rather than a +bundle that could not have been signed, and the default install stops at the +library. The library and the header build 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 -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. +to. An `.app` is a directory with a plist, a binary, a shader 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 four `addInstallFileWithDir` calls +already do (`install_app_bin`, `install_plist`, `install_scene_kernel`, +`install_icon`). - **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed `src/macos/Info.plist` and writes a stamped copy into the cache. The source @@ -784,16 +837,24 @@ system to keep in step for what three `addInstallFileWithDir` calls already do. `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: +- **The scene kernel.** `shaders/crt.ci.metal` is installed verbatim as + `Contents/Resources/crt.ci.metal` and compiled at runtime with + `CIKernel.kernels(withMetalString:)`. The same file is also an anonymous + module import named `effect-source-crt.ci.metal`, added by `build.zig`'s + per-shell module wiring under `if (shell == .macos)`, which is what lets + `EffectCode` print the exact source the app executes. +- **The link.** One swiftc invocation, with the optimize mode following + `-Doptimize` — `-Onone` for Debug, `-Osize` for ReleaseSmall, `-O` otherwise + — so both halves of the app are built the same way: ```sh -swiftc -O -target -apple-macos13.0 \ +swiftc <-O|-Osize|-Onone> -target -apple-macos13.0 \ -import-objc-header src/macos/pardes.h \ -o /pardes \ - src/macos/Sources/{main,AppDelegate,PardesView}.swift \ + src/macos/Sources/{main,AppDelegate,PardesView,ScenePostprocessor,FileWatcher}.swift \ /libpardes.a -lc++ \ - -framework AppKit -framework CoreText -framework CoreGraphics + -framework AppKit -framework CoreText -framework CoreGraphics \ + -framework CoreImage -framework Metal ``` The arch is derived from the build target, which defaults to the host — `arm64` @@ -846,10 +907,13 @@ for letting something other than this app link the core. This ships arm64. 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: +**The Linux dev loop.** The Zig half of this backend is plain POSIX, and most +of it is no longer even this backend's: `forkShell`, `writeFileBytes` and +`writeFd` are `src/host_io.zig`'s, byte-identical on both systems, and what is +left that differs is `ioctl(TIOCSWINSZ)` (absent from `std.c.T` on darwin), +`/usr/bin/open` against `/usr/bin/xdg-open` (`src/look.zig:33-37`), and libproc +against `/proc`. So `-Dplatform=macos` compiles on a Linux host, and its tests +run there: ```sh zig build unit-test -Dplatform=macos @@ -897,7 +961,8 @@ 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 `, `force`, `rotate [gap_ms]`, `rotate_end`, +exists here: `fingers `, `force `, +`rotate [gap_ms]`, `rotate_end`, `drop `, `scroll `, `haptic `, `nsclick` (the AppKit-event path, as opposed to `click`'s direct entry-point call), `font `, `font-size `, `zoom`, @@ -983,6 +1048,20 @@ used for the table and is not folded into the direct-path claim. - **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. +- **Detached sessions.** `Attach` (`SPC s a`) and `Detach` (`SPC s D`) exist on + every hosted platform, this one included, because `Builtin.enabled` is + `pardes.hosted`. Neither works here. `Detach` emits `Effect.detach`, this + host fills in no `push_detach`, and `Pardes.perform` therefore reports + `error.NotAttached` on the pane's message row (`src/pardes.zig:6837`). + `Attach` emits `Effect.attach`, which `perform` turns into an `attach_req` + the shell is supposed to consume from OUTSIDE `pump` with `takeAttach` — and + `src/macos.zig` never calls `takeAttach`, so the word does nothing at all and + says nothing either. Wiring it up means a unix-socket frontend loop beside + the AppKit one, which is `src/detached/client.zig`'s job in the tty and SDL + shells; see `docs/detached.md`. +- **`tty_taken` on darwin.** The pull is wired (`ttyTaken`, `src/macos.zig:1838`) + but `look.ttyTaken` has no libproc implementation and answers `false`, so an + `Exec` here always believes the pane is still at the prompt it forked. - **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 -- cgit v1.3