summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig59
-rw-r--r--docs/ghostty-macos-notes.md198
-rw-r--r--docs/macos.md252
-rw-r--r--src/look.zig2
-rw-r--r--src/macos.zig882
-rw-r--r--src/macos/Info.plist20
-rw-r--r--src/macos/Sources/AppDelegate.swift179
-rw-r--r--src/macos/Sources/PardesView.swift478
-rw-r--r--src/macos/Sources/main.swift16
-rwxr-xr-xsrc/macos/build-app.sh45
-rw-r--r--src/macos/pardes.h204
-rw-r--r--src/main.zig5
-rw-r--r--src/pardes.zig6
13 files changed, 2339 insertions, 7 deletions
diff --git a/build.zig b/build.zig
index feefecdc..8a07cf16 100644
--- a/build.zig
+++ b/build.zig
@@ -2,7 +2,7 @@ const std = @import("std");
const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
-pub const Platform = enum { tty, gui, web };
+pub const Platform = enum { tty, gui, web, macos };
/// The ZLS the language backend links, spelled once. It names the commit
/// build.zig.zon pins (0.16.x branch) and is passed BOTH to ZLS's own
@@ -24,7 +24,7 @@ pub fn build(b: *std.Build) void {
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
} });
const requested_optimize = b.standardOptimizeOption(.{});
- const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web)") orelse .tty;
+ const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty;
const static = b.option(bool, "static", "statically link") orelse false;
const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
const is_web = platform == .web;
@@ -96,7 +96,13 @@ pub fn build(b: *std.Build) void {
const root_mod = b.createModule(.{
.target = target,
.optimize = optimize,
- .root_source_file = b.path(if (is_web) "src/web.zig" else "src/main.zig"),
+ // The browser and macOS shells are libraries whose host owns main():
+ // each roots at its own flat C ABI instead of src/main.zig.
+ .root_source_file = b.path(switch (platform) {
+ .web => "src/web.zig",
+ .macos => "src/macos.zig",
+ .tty, .gui => "src/main.zig",
+ }),
.link_libc = !is_web,
});
// helix differential harness (test/hxdiff.zig): a second compilation of
@@ -635,6 +641,53 @@ pub fn build(b: *std.Build) void {
run_web_harness.step.dependOn(web_step);
b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step);
b.getInstallStep().dependOn(web_step);
+ } else if (platform == .macos) {
+ // The native macOS shell is a static library plus a Swift app: Zig
+ // keeps the core, the ptys and every effect; Swift owns AppKit and
+ // draws the cell grid with CoreText. See docs/macos.md.
+ //
+ // The Zig half is ordinary POSIX and builds anywhere, which is what
+ // makes the boundary testable without a Mac — only `macos-app` needs
+ // one. Deliberately NOT here: an xcframework, lipo, an Xcode project
+ // and codesigning. Those exist to ship a signed universal bundle, and
+ // this is a dev build; ghostty's src/build/GhosttyXCFramework.zig is
+ // the map when distribution matters.
+ const header = b.addTranslateC(.{
+ .root_source_file = b.path("src/macos/pardes.h"),
+ .target = target,
+ .optimize = optimize,
+ });
+ // The header is hand-written, so only a test keeps it in step with the
+ // Zig side. Importing the translated header makes it a checkable
+ // artifact — see the ABI guard at the bottom of src/macos.zig.
+ root_mod.addImport("pardes.h", header.createModule());
+
+ const lib = b.addLibrary(.{
+ .name = "pardes",
+ .linkage = .static,
+ .root_module = root_mod,
+ });
+ // Swift links this archive directly, so the runtime support Zig would
+ // otherwise expect from a Zig-linked executable has to travel inside
+ // it or every build ends in undefined symbols at the swiftc link.
+ lib.bundle_compiler_rt = true;
+ lib.bundle_ubsan_rt = true;
+ b.installArtifact(lib);
+ b.installFile("src/macos/pardes.h", "include/pardes.h");
+
+ const app = b.addSystemCommand(&.{"src/macos/build-app.sh"});
+ app.addArg(b.getInstallPath(.prefix, ""));
+ app.has_side_effects = true; // writes a bundle outside the cache
+ app.step.dependOn(b.getInstallStep());
+ b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)").dependOn(&app.step);
+
+ // Same step name the other native platforms use, because in this
+ // configuration theirs is not declared: the ABI guard and the core's
+ // own inline tests are what `-Dplatform=macos` has to keep green.
+ const unit_step = b.step("unit-test", "run the C-ABI guard and the core unit tests");
+ unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod })).step);
+ unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = hx_core_mod })).step);
+ web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
} else {
// binary name: the native-shaped linux-x86_64 tty build stays `pardes`
// (the snap suite and e2e harness drive zig-out/bin/pardes); the gui
diff --git a/docs/ghostty-macos-notes.md b/docs/ghostty-macos-notes.md
new file mode 100644
index 00000000..556831af
--- /dev/null
+++ b/docs/ghostty-macos-notes.md
@@ -0,0 +1,198 @@
+# Notes: how ghostty integrates Zig with Swift
+
+Research notes taken while designing `docs/macos.md`, kept so the next person
+does not have to read ghostty again. Line references are against the checkout at
+`~/05-genizah/ghostty` (commit `ba38b493`, "Update to Zig 0.16.0"); they will
+drift, the shapes will not.
+
+Ghostty is the only large Zig program with a shipping native macOS app, so it is
+the reference whether or not you copy it. Read this next to `docs/macos.md`,
+which records where pardes deliberately went the other way.
+
+## The seam is small, and it is a hand-written C header
+
+`include/ghostty.h` is 1207 lines for an application of ghostty's size: about 90
+exported functions, four opaque handles (`typedef void* ghostty_app_t` and
+friends, `:56-60`), and one callback struct. Everything is a `void*` on the C
+side; the Zig `export fn` signatures carry the real types, so the compiler checks
+one end and the other is untyped by construction.
+
+The header is **not generated**. It is maintained by hand, with `IMPORTANT: Any
+changes here update include/ghostty.h` comments scattered through the Zig
+(`src/input/key.zig:83`, `src/apprt/action.zig:75`). What keeps it honest is a
+test: `build.zig:359-365` runs `addTranslateC` over the header and imports the
+result into the unit-test build, and `src/lib/enum.zig:91-141` walks every Zig
+enum asserting each tag equals the matching `GHOSTTY_*` constant, failing in both
+directions — a Zig tag with no C constant, and a C constant with no Zig tag.
+
+This is the single most transferable thing in the repository, it costs about ten
+lines of build plumbing, and it runs on Linux. pardes now does the same thing in
+`src/macos.zig`, extended to compare exported function arity and scalar widths as
+well, because a hand-written header drifts in signatures too.
+
+## Zig→host is one callback, not N
+
+`ghostty_runtime_config_s` (`include/ghostty.h:999-1027`) holds `userdata` and
+six function pointers: `wakeup`, `action`, `read_clipboard`,
+`confirm_read_clipboard`, `write_clipboard`, `close_surface`. Only `wakeup` and
+the clipboard ones are what they look like. Everything else the core wants from
+the host — new window, set title, toggle fullscreen, desktop notification,
+sixty-odd cases — travels through the single `action` callback as a tagged union.
+
+The union is generated at comptime from the action enum
+(`src/apprt/action.zig:419-484`) with `@Union(.@"extern", ...)`, and asserted to
+a fixed size so adding a case cannot silently move the ABI:
+
+```zig
+comptime {
+ assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { 4 => 16, 8 => 24, else => unreachable });
+}
+```
+
+The payoff is that adding a feature adds an enum case, not a struct field: hosts
+that do not implement it return `false` and the ABI never changed. Worth copying
+the day a fixed callback list starts growing. pardes has two callbacks today and
+does not need it yet.
+
+Two userdata scopes, worth noting: the runtime struct's `userdata` is
+app-scoped, and each surface carries its own. Swift round-trips both with
+`Unmanaged.passUnretained(self).toOpaque()` — libghostty never retains, so
+lifetime stays entirely on the Swift side.
+
+## Rendering: Zig owns the layer, Swift never draws
+
+This is the surprising part and the main thing pardes did *not* copy.
+
+Swift creates an `NSView`, gives it a non-zero frame, and passes the bare
+pointer through a tagged platform union (`include/ghostty.h:448-465`):
+
+```c
+typedef struct { void* nsview; } ghostty_platform_macos_s;
+```
+
+Zig then makes that view layer-hosting and installs a layer *it* owns
+(`src/renderer/Metal.zig:108-146`):
+
+```zig
+var layer = try IOSurfaceLayer.init();
+info.view.setProperty("layer", layer.layer.value);
+info.view.setProperty("wantsLayer", true);
+```
+
+Note the order — assigning `.layer` before `wantsLayer = true` is what makes the
+view layer-*hosted*, meaning AppKit will never draw into it. And it is not a
+`CAMetalLayer` or an `MTKView`: `src/renderer/metal/IOSurfaceLayer.zig` builds a
+`CALayer` subclass at runtime with `objc.allocateClassPair`, renders into an
+`IOSurface`, and sets it as `layer.contents`.
+
+The consequence is that Swift is not in the render loop at all. There is no
+`ghostty_surface_draw` call anywhere in the Swift sources; the renderer thread
+drives its own vsync. Swift pushes only metadata: `set_size` (from a SwiftUI
+`GeometryReader`, not `NSView.resize` — macOS 12 did not call it),
+`set_content_scale` from `viewDidChangeBackingProperties`, `set_display_id` so
+Zig can pick the right refresh rate, and `set_occlusion`.
+
+The debug inspector is the counter-example and shows both models are supported:
+there Swift owns an `MTKView` and passes a command buffer *into* Zig
+(`ghostty_inspector_metal_render`, `macos/Sources/Ghostty/Surface View/InspectorView.swift:417`).
+
+pardes inverts this deliberately — see `docs/macos.md`. The short version: a
+terminal grid rendered by a hand-written Metal atlas is what `src/gui/gui.zig`
+already spends thousands of lines on, and a cell grid is exactly what CoreText
+draws natively.
+
+## Threading
+
+Per surface, ghostty spawns two threads (`src/Surface.zig:727-741`): a renderer
+thread owning Metal and its own frame clock, and an IO thread doing pty reads and
+VT parsing. Both run libxev loops with a `BlockingQueue` mailbox.
+
+Everything in `ghostty.h` is main-thread-only. The one documented exception is
+`wakeup`, and its whole Swift body is a hop:
+
+```swift
+DispatchQueue.main.async { state.appTick() }
+```
+
+The detail worth stealing: the wakeup is baked into the mailbox push
+(`src/App.zig:559-576`), so it is impossible to enqueue without waking. Worker
+threads never call host callbacks directly — `action_cb` fires from
+`drainMailbox` on the main thread, so although `wakeup` is any-thread, in
+practice every other callback is not.
+
+## Build plumbing, and how much of it is distribution
+
+Two build systems glued together, and ghostty's own CI never uses the integrated
+path — `zig build -Demit-macos-app=false` then `xcodebuild`, because Nix breaks
+xcodebuild (`.github/workflows/release-tip.yml:436-441`, and `macos/AGENTS.md`
+says the same to humans).
+
+The Zig half:
+
+- `src/build/GhosttyLib.zig:19-58` — a static lib rooted at `src/main_c.zig`
+ with `bundle_compiler_rt = true` and `bundle_ubsan_rt = true`, then every
+ dependency archive merged into one. The comment is blunt: *"These must be
+ bundled since we're compiling into a static lib. Otherwise, you get undefined
+ symbol errors."* pardes's `build.zig` copies exactly this.
+- `LibtoolStep.zig` / `CombineArchivesStep.zig` — `libtool -static` on Darwin,
+ an MRI script through `zig ar -M` elsewhere. Note the workaround at
+ `LibtoolStep.zig:54-78`: newer Xcode `libtool` drops 64-bit archive members
+ unless each input is copied and `ranlib`'d first.
+- `LipoStep.zig` — 42 lines, `lipo -create`, fed by building the same static lib
+ twice against retargeted deps (`GhosttyLib.zig:169-199`).
+- `XCFrameworkStep.zig` — 77 lines, `xcodebuild -create-xcframework`. It writes
+ to a fixed repo-relative path outside the Zig cache, which is why it needs
+ `has_side_effects` and an explicit `rm -rf`; the price of a stable path for
+ Xcode to reference.
+- `GhosttyXCFramework.zig:56-60` stages a headers directory containing *only*
+ `ghostty.h` plus `module.modulemap`, because pointing at `include/` directly
+ drags in the separate libghostty-vt headers and Clang complains that the
+ umbrella header does not include them.
+
+The Xcode side needs almost nothing: one `PBXFileReference` for the xcframework
+with `sourceTree = "<group>"`, added to the frameworks build phase, plus
+`OTHER_LDFLAGS = -lstdc++` for the vendored C++. No xcconfig, no search paths, no
+bridging header for libghostty — Swift says `import GhosttyKit` and the module
+map inside the framework does the rest. (The bridging header that does exist is
+for two unrelated Objective-C helpers.)
+
+Resources are Xcode *folder references* pointing at `../zig-out/share/*`, so Zig
+writes the tree and Xcode copies it verbatim. Fonts are not resources at all —
+they are `@embedFile`'d (`src/font/embedded.zig`). Metal shaders are compiled to
+a `.metallib` by `xcrun` at Zig build time and embedded into the library
+(`MetallibStep.zig`, `SharedDeps.zig:504-511`), which is much simpler than
+routing shaders through Xcode build rules.
+
+**What genuinely needs a Mac:** `libtool`, `lipo`, `xcodebuild`, `xcrun metal`,
+`dsymutil`, `codesign`, and Swift itself. Everything else — including the
+header-drift test — runs on Linux. `flake.nix:63-66` excludes Darwin from
+buildable platforms entirely.
+
+pardes skips lipo, the xcframework, the Xcode project and codesigning. All four
+exist to ship a signed universal bundle to someone else's machine; a dev build
+needs a `swiftc` invocation and a directory with a plist in it. They are the
+named upgrade path in `docs/macos.md`, in that order.
+
+## Small things worth remembering
+
+- Enum value 0 is reserved as "invalid" throughout the C API
+ (`PlatformTag` at `src/apprt/embedded.zig:390-395`), so a zero-initialized
+ struct is detectable, and tags are validated with `intToEnum` rather than
+ trusted.
+- Zig errors never cross the boundary. Every export is a thin wrapper:
+ `fn foo_() !T` plus `catch |err| { log; return null; }`
+ (`src/apprt/embedded.zig:1398-1406`). There is no error channel at all.
+- The exports live inside `pub const CAPI = struct {...}` and are only emitted
+ because `src/main_c.zig:34-47` references the struct in a `comptime` block.
+- libghostty defends against host garbage rather than trusting it —
+ `updateContentScale` rejects NaN and clamps below 1 with the comment *"We are
+ an embedded API so the caller can send us all sorts of garbage"*
+ (`src/apprt/embedded.zig:777-793`), and `updateSize` drops no-op resizes
+ because *"Runtimes sometimes generate superfluous resize events even if the
+ size did not actually change (SwiftUI)"*.
+- Swift keeps a dependency-free `GhosttyPackageMeta.swift` so auxiliary bundles
+ (the dock tile plugin) can share types without linking the Zig library.
+- The Swift wrapper layer compiles unchanged for iOS, which is the real proof
+ that the C seam is the right width. It manages that by turning Zig→host
+ actions into `NSNotification`s keyed on the surface, decoupling the callback
+ from any particular view controller.
diff --git a/docs/macos.md b/docs/macos.md
new file mode 100644
index 00000000..35fba5f3
--- /dev/null
+++ b/docs/macos.md
@@ -0,0 +1,252 @@
+# Native macOS backend
+
+Zig owns the core, the ptys, every effect and the worker threads. Swift owns
+`NSApplication`, the window, input translation, and drawing. They meet at a
+hand-written C ABI, `src/macos/pardes.h`, built as a static library that the app
+links; the Swift half is ordinary AppKit that pumps events in and reads one
+packed cell buffer out. There is exactly one core per process, so the ABI has no
+handles — the state is a file-scoped singleton and every function names it
+implicitly, exactly like the browser backend.
+
+The research this design came out of is written down separately in
+`docs/ghostty-macos-notes.md` — how ghostty wires Zig to Swift, what its build
+plumbing actually requires, and which parts of it are distribution machinery
+rather than integration. Read that for the alternatives; this file is what was
+built and why.
+
+## Why not ghostty's split
+
+Ghostty was read carefully before this was written, and this backend
+deliberately inverts its division of labour. Ghostty hands Zig a bare `NSView*`
+through a tagged platform union (`ghostty_platform_macos_s.nsview`), and Zig
+then creates a layer, assigns it as the view's layer, sets `wantsLayer`, and
+installs a display callback — `src/renderer/Metal.zig` in that tree. Swift never
+renders a glyph; it supplies a rectangle and gets out of the way.
+
+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,300 lines are a 2048² stb_truetype R8 atlas,
+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,
+never once run — whereas CoreText drawing is a small amount of Swift that only
+exists on the machine that can run it.
+
+The ceiling is accepted and named. Per-cell CoreText drawing is slower than an
+atlas, and if it fails to hold a full-screen redraw at the target rate the
+escalation is ghostty's model verbatim: Zig owns a `CAMetalLayer` installed into
+the view and drives its own frame clock, and the ABI grows a `platform` pointer
+field carrying the `NSView*` — one field, because the rest of the seam does not
+change. Nothing here is designed to make that harder.
+
+## Why the ABI mirrors src/web.zig
+
+The browser and a Cocoa app are the same host, and the browser proved the shape
+first. In both, someone else owns the clock and the event loop; events arrive
+through flat functions that take scalars and borrowed byte ranges; one call
+renders, and the result is one contiguous array of packed cells the host walks
+linearly. `pardes_cell_s` is byte-for-byte `WebCell`: seven UTF-8 bytes of
+grapheme plus a guaranteed zero, tagged `fg`/`bg` words (`0x01000000` default,
+`0x02000000 | index` for the palette, otherwise `0x00RRGGBB`), an attribute
+bitfield with the underline style in the high bits, and a `flags` bit meaning
+"the core never painted this cell" so a host can draw background only and skip
+the glyph. Two hosts spelling the same encoding is not duplication worth
+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 effects never cross 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 two callbacks and not
+twelve.
+
+## The seam
+
+**Frame.** `pardes_frame` renders and returns the cell count;
+`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.
+
+**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
+characters. Keys that carry no text are the four ASCII controls (enter, escape,
+tab, backspace) and a private-use block starting at `0xF0001` for the arrows and
+navigation keys — private-use precisely so a functional key can never be
+confused with a real codepoint arriving as text. `pardes_mouse` speaks acme's
+vocabulary: three buttons, where 1 selects, 2 executes and 3 looks, with wheel
+directions as ordinary buttons rather than a separate axis. Ctrl is the only
+modifier the core consults (a left press with ctrl is goto-definition), but the
+full mask is passed anyway to keep the signature identical to the web one.
+`pardes_scroll` carries a fractional row delta from a trackpad; the Zig side
+accumulates it and synthesizes whole `wheel_up`/`wheel_down` presses, because
+the core scrolls on button events and its `touch_scroll` event only records the
+residual for the debug overlay. `src/gui/gui.zig` (`takeScrollTicks`) and
+`src/web/app.mjs` both do exactly this already. A real mouse notch skips the
+smoothing and goes through `pardes_mouse`. `pardes_resize` also carries one cell
+in *physical* pixels, which only the native PDF placement path reads — pass the
+backing-store size, not points.
+
+**Runtime callbacks.** `pardes_runtime_s` is a `userdata` and two functions,
+copied by value during init so the struct need not outlive the call. `wakeup`
+means "your state moved, please pump me"; `set_clipboard` fires inside a tick
+when the yank register changes, with the text borrowed for the duration of the
+call. There is no `open_url` callback because the core already opens links
+itself through `/usr/bin/open` (`src/look.zig`), and no file callbacks because
+it owns the filesystem side too.
+
+**Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code,
+`pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect
+queue and returns whether anything changed, `pardes_should_quit` reports the
+Exit builtin or the last pane closing, and `pardes_animating` says a theme
+transition wants ~60 Hz ticks until it settles — the one thing in an otherwise
+event-driven frontend that redraws on a clock, handled in `tty.zig` by sleeping
+the loop and forcing a tick.
+
+The ordering contract is the part a header cannot enforce. **Init with the real
+grid size.** The core defers each shell's greeting until it has seen a resize:
+`sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0`
+and the pty has produced its first prompt. Setting `Options.cols`/`rows` alone
+never bumps that counter, so init must turn its arguments into an actual resize
+event the way `src/web.zig` does immediately after construction — and the size
+must be true, because the first `forkpty` takes its winsize from the core's
+current grid and a shell booted at a placeholder draws its first prompt at the
+wrong width. **Call `pardes_tick` after every input function.** The input calls
+only advance the state machine; the writes to the pty, the spawns, the saves all
+happen in the drain, so an input without a following tick is an input that
+visibly did nothing. **`wakeup` is the only any-thread entry point in the whole
+ABI.** Everything else is main-thread only, and the host's `wakeup` must do
+nothing but hop — one `DispatchQueue.main.async` that calls `pardes_tick` and
+marks the view dirty.
+
+## 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.
+
+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
+and must render independently of input. Pardes does not own the clock here — the
+host does, through `wakeup` and dirty rects — so there is no third thread to
+synchronize and no mailbox protocol to get wrong.
+
+## Building
+
+Two commands, because they are two different machines' problems.
+
+```sh
+zig build -Dplatform=macos
+```
+
+produces `zig-out/lib/libpardes.a` and installs `zig-out/include/pardes.h`
+beside it. This is ordinary Zig cross-compilation and runs anywhere, which is
+the whole point of the next section.
+
+```sh
+zig build macos-app -Dplatform=macos # on a Mac; or run the script directly
+```
+
+runs `src/macos/build-app.sh`, which is the whole second half:
+
+```sh
+swiftc -O -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++ \
+ -framework AppKit -framework CoreText -framework CoreGraphics
+```
+
+plus copying `Contents/Info.plist`, and the bundle is done. 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. `-lc++` is there because
+ghostty-vt pulls in simdutf and highway, which are C++ — the Zig side bundles
+`compiler_rt` and `ubsan_rt` into the archive (`bundle_compiler_rt` in
+`build.zig`), so the C++ runtime is the only thing left for this link to supply.
+Without that bundling the swiftc link ends in undefined symbols, which is the
+first thing ghostty's `GhosttyLib.initStatic` does too.
+
+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.
+
+## 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:
+
+```sh
+zig build unit-test -Dplatform=macos
+```
+
+Only the Swift app needs a Mac. That is what makes this scaffold verifiable
+rather than dead code: the ABI's Zig side, the pty plumbing and the effect drain
+are all exercised on the machine they were written on, and the part that cannot
+be is small, visible, and made of AppKit calls.
+
+The header is kept honest with ghostty's trick. `build.zig` runs `translate-C`
+over `src/macos/pardes.h` into the unit-test build, and `src/macos.zig`
+asserts every constant and every struct layout against the Zig side — the color
+tags, the attribute bits, the key codepoints, the mouse and modifier ordinals,
+`@sizeOf(pardes_cell_s)` and each field offset. A hand-written header is a
+second source of truth, and the only defensible way to keep one is a test that
+fails the moment the two disagree.
+
+A second test compares every exported function's arity and scalar widths against
+the header's declaration. It is not a type equality — `translate-C` spells
+pointers `[*c]` and mints its own struct types, so nothing would ever match
+exactly — but arity and width are what actually break. It earned its place
+immediately: `pardes_scroll` grew a cell coordinate after the Swift view had
+already been written against the one-argument form, and nothing but a human
+reading both files would have caught it.
+
+## Not implemented
+
+- **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.
+- **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
+ `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
+ either.
+- **IME and marked text.** Only finished characters reach `pardes_key`. Real
+ composition means implementing `NSTextInputClient` and giving the core a way
+ to render an underlined preedit run, which no backend has yet.
+- **Tabs and splits at the window level.** One window, one grid. 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.
+- **App-bundle resources.** The plist is the minimum that makes a windowed app:
+ no icon, no bundled fonts, no asset catalog, no localization.
+- **argv and file-open handling.** No positional path, no `-l` dump load, and no
+ `application:openFile:`. The first pane spawns with an empty cwd, so the shell
+ inherits the process's — which for a bundle launched from Finder is `/`, and
+ is the first thing worth fixing here.
diff --git a/src/look.zig b/src/look.zig
index 4a8cb894..273e20c3 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -328,7 +328,7 @@ fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sourc
}
const platform_has_fs = switch (pardes.platform) {
- .tty, .gui => true,
+ .tty, .gui, .macos => true,
.web => false,
};
diff --git a/src/macos.zig b/src/macos.zig
new file mode 100644
index 00000000..8eb6179a
--- /dev/null
+++ b/src/macos.zig
@@ -0,0 +1,882 @@
+//! libpardes — the static library the native macOS app links against.
+//!
+//! The split, which is the whole design: Zig keeps the core, the ptys, every
+//! effect and the worker threads; Swift owns NSApplication, the window, input
+//! translation and drawing. src/macos/pardes.h is the contract between them and
+//! docs/macos.md argues for the shape.
+//!
+//! This is deliberately src/web.zig's boundary with the wasm removed. Both
+//! hosts are the same animal — someone else owns the clock, feeds events in
+//! through flat functions and reads one packed cell buffer out — and the
+//! browser already proved the shape works. The one real divergence is that the
+//! browser has no processes, so it forwards every effect to JavaScript, whereas
+//! forkpty is right here and this file performs them.
+//!
+//! Everything below is main-thread only. The single exception is the `wakeup`
+//! callback, which a pty reader task calls; the host's job is to hop to the
+//! main thread and call pardes_tick.
+//!
+//! The Zig half is ordinary POSIX and builds/tests on Linux — see the dev-loop
+//! section of docs/macos.md. Only the Swift app needs a Mac.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const posix = std.posix;
+const libc = std.c;
+const pardes = @import("pardes.zig");
+const look = @import("look.zig");
+const temp_file = @import("temp_file.zig");
+const shell_bin = @import("shell_bin.zig");
+const message = @import("message.zig");
+const user_config = @import("user_config.zig");
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+
+// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize). Same
+// constant the tty and gui shells spell for the same reason.
+const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
+
+// A library linked into an AppKit process has no terminal to garble, but it
+// does share the app's stderr with Console.app. Same filter as src/main.zig:
+// ghostty-vt narrates every unimplemented escape a child writes, and nobody
+// wants that in a crash report. PARDES_LOG=1 gets the real logger back.
+pub const std_options: std.Options = .{ .logFn = logFn };
+
+fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ if (scope != .macos and scope != .dump and std.c.getenv("PARDES_LOG") == null) return;
+ std.log.defaultLog(level, scope, format, args);
+}
+
+const log = std.log.scoped(.macos);
+
+// ---------------------------------------------------------------- boundary
+
+/// Sync with: pardes_cell_s. The identical encoding is spelled a second time
+/// for the browser as WebCell in src/web.zig.
+///
+/// ponytail: two copies of a fifteen-line pure encoder, not a shared module.
+/// The web ABI is snapshot-tested through a headless Chrome that does not run
+/// here, so extracting it would refactor a backend I cannot exercise to save
+/// thirty lines. Merge them the day a third host wants the same bytes.
+pub const Cell = extern struct {
+ text: [8]u8,
+ fg: u32,
+ bg: u32,
+ attrs: u16,
+ len: u8,
+ flags: u8,
+};
+
+/// Sync with: pardes_runtime_s. Two callbacks, because everything else the
+/// core asks for it already does itself — it owns the ptys, and look.openLink
+/// hands URLs to /usr/bin/open. Both are optional at the ABI level: a host that
+/// passes null simply does without, rather than trapping inside the library.
+pub const Runtime = extern struct {
+ userdata: ?*anyopaque = null,
+ wakeup: ?*const fn (?*anyopaque) callconv(.c) void = null,
+ set_clipboard: ?*const fn (?*anyopaque, [*]const u8, usize) callconv(.c) void = null,
+};
+
+const color_default: u32 = 0x01000000;
+const color_indexed: u32 = 0x02000000;
+const cell_flag_default: u8 = 1;
+
+// ---------------------------------------------------------------- state
+
+/// One pty, and the task draining it. `gen` is the per-slot spawn generation:
+/// the core reuses pane ids and has no close effect, so a respawned slot must
+/// ignore the previous shell's late bytes rather than feed them to the new one.
+const Pty = struct {
+ file: std.Io.File,
+ pid: posix.pid_t,
+ gen: u32,
+ reader: std.Io.Future(anyerror!void),
+};
+
+/// What a reader task hands the main thread. `gen` travels with the message so
+/// a shell that was replaced while its read was in flight cannot have its
+/// stragglers parsed into the pty that took its slot.
+const Msg = union(enum) {
+ output: struct { pane: u8, gen: u32, bytes: []u8 },
+ eof: struct { pane: u8, gen: u32 },
+
+ fn free(m: Msg, gpa: std.mem.Allocator) void {
+ switch (m) {
+ .output => |o| gpa.free(o.bytes),
+ .eof => {},
+ }
+ }
+};
+
+/// 0.16 has no std.Thread.Mutex, and the gui shell's queue already settled
+/// this: spin on the lock-free std.atomic.Mutex, and keep the critical section
+/// to a pointer append. The reader allocates its chunk BEFORE taking the lock
+/// for exactly that reason — a screenful of output must not make a keystroke
+/// spin through a 64 KiB copy.
+///
+/// ponytail: unbounded. `yes` in a pane can enqueue faster than the host
+/// drains, and nothing throttles the reader — the tty shell gets that for free
+/// from vaxis's 512-slot queue, whose post blocks when full, and the SDL shell
+/// has the same hole this does. Bound it on queued bytes the day someone
+/// watches RSS climb; the trap to avoid is a wait that teardown cannot cancel.
+const Inbox = struct {
+ mutex: std.atomic.Mutex = .unlocked,
+ items: std.ArrayList(Msg) = .empty,
+ /// Set when a wakeup has been delivered and not yet answered by a tick.
+ /// Without it a busy shell posts one wakeup per 64 KiB chunk, and each one
+ /// is a block on the host's main queue — a screenful of output becomes
+ /// thousands of scheduled pumps that all find the same drained inbox.
+ wake_pending: std.atomic.Value(bool) = .init(false),
+
+ fn lock(q: *Inbox) void {
+ // Bounded, unlike the gui shell's otherwise identical spin. AppKit's
+ // main thread runs at a higher QoS than these reader tasks and a raw
+ // CAS spin donates no priority, so a reader preempted inside the append
+ // (which can realloc) would have the highest-priority thread in the
+ // process spinning on it. Yielding hands the core back.
+ var spins: u8 = 0;
+ while (!q.mutex.tryLock()) {
+ spins +%= 1;
+ if (spins == 0) std.Thread.yield() catch {} else std.atomic.spinLoopHint();
+ }
+ }
+
+ fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void {
+ q.lock();
+ defer q.mutex.unlock();
+ q.items.append(gpa, m) catch m.free(gpa);
+ }
+};
+
+const State = struct {
+ gpa: std.mem.Allocator,
+ threaded: *std.Io.Threaded,
+ io: std.Io,
+ core: *pardes.Pardes,
+ arena: std.heap.ArenaAllocator,
+ runtime: Runtime,
+ cells: std.ArrayList(Cell) = .empty,
+ /// The grid `cells` actually holds. Not read back off the core: a render
+ /// can move screen_w/screen_h and then fail, and a host that sized its
+ /// loops from those would walk off the buffer.
+ frame_cols: u16 = 0,
+ frame_rows: u16 = 0,
+ ptys: [pardes.MAX_PANES]?Pty = @splat(null),
+ inbox: Inbox = .{},
+ /// Per-slot spawn generation, owned by the main thread. A reader carries a
+ /// copy in every message it posts; anything that no longer matches belongs
+ /// to a shell this slot has already replaced.
+ gens: [pardes.MAX_PANES]u32 = @splat(0),
+ /// Sub-row wheel distance the core has not been told about yet. The core
+ /// moves a whole row at a time, so fractional trackpad travel accumulates
+ /// here and is spent as wheel presses — see pardes_scroll.
+ scroll_lag: f32 = 0,
+ /// Swapped with the inbox under the lock, so draining it costs one pointer
+ /// exchange and neither side reallocates once capacities have settled.
+ tick_msgs: std.ArrayList(Msg) = .empty,
+ /// Owns the bytes of the user config, which Options only borrows.
+ config_arena: std.heap.ArenaAllocator,
+};
+
+var state: ?State = null;
+
+// ---------------------------------------------------------------- lifecycle
+
+export fn pardes_init(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) c_int {
+ if (state != null) return 1; // already up; deinit first
+ initCore(runtime, cols_arg, rows_arg) catch |err| {
+ log.err("init failed: {t}", .{err});
+ return 2;
+ };
+ return 0;
+}
+
+/// The body is split out purely so the cleanup below is real: `errdefer` fires
+/// on an error return and nothing else, so writing this inside an export that
+/// returns c_int would leave every one of these as dead code — and a half-built
+/// init leaks an arena, leaves zstbi pointing at a dead allocator, and (because
+/// Io.Threaded installs process-wide SIGIO/SIGPIPE handlers that only its
+/// deinit restores) hands those handlers permanently to the host app.
+fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
+ const gpa = std.heap.smp_allocator;
+
+ const threaded = try gpa.create(std.Io.Threaded);
+ errdefer gpa.destroy(threaded);
+ threaded.* = .init(gpa, .{});
+ errdefer threaded.deinit();
+ const io = threaded.io();
+
+ var config_arena: std.heap.ArenaAllocator = .init(gpa);
+ errdefer config_arena.deinit();
+
+ var opts: pardes.Options = .{ .tty_only = true };
+ // Native shells opt into the user config, and every builtin in it must have
+ // run before the host can render a frame — so it is read here, before
+ // Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from
+ // libc's environ because a library has no std.process.Init to inherit one.
+ if (captureEnv(config_arena.allocator())) |*env|
+ opts.startup_config = user_config.load(io, config_arena.allocator(), env);
+
+ // stb_image's allocator shim, for image panes.
+ pardes.image.start(io, gpa);
+ errdefer pardes.image.stop();
+
+ const core = try pardes.Pardes.init(gpa, opts);
+ errdefer core.deinit();
+
+ // Shells emit OSC 133 prompt marks through these, which is what makes
+ // prompt hiding and click-to-move work.
+ writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
+ writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Apple's bash 3.2 prints the zsh-deprecation banner into every pane unless
+ // this is in the environment BEFORE bash starts — the rc file is too late.
+ if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
+
+ state = .{
+ .gpa = gpa,
+ .threaded = threaded,
+ .io = io,
+ .core = core,
+ .arena = .init(gpa),
+ .config_arena = config_arena,
+ .runtime = if (runtime) |r| r.* else .{},
+ };
+ const st = &state.?;
+
+ // The real grid, delivered as an EVENT and not as Options.cols/rows: the
+ // core defers each shell's greeting until it has seen a resize, and the
+ // first forkpty below takes its winsize straight off the core.
+ const cols = @max(1, cols_arg);
+ const rows = @max(1, rows_arg);
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+
+ // The initial spawns happen before any reader task exists, mirroring the
+ // tty shell. Note the difference in what that buys: tty.zig runs from
+ // main() and really is single-threaded there, whereas this is called from
+ // applicationDidFinishLaunching, by which point AppKit and libdispatch
+ // have long since spawned threads. What keeps the fork safe is the child
+ // itself — chdir and execv, raw syscalls with nothing allocated between
+ // fork and exec — not the thread count. Ordering it this way anyway keeps
+ // the two backends readable side by side.
+ _ = drainEffects(st, false);
+ for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id));
+}
+
+export fn pardes_deinit() void {
+ const st = &(state orelse return);
+ // Every reader is joined here, before anything it touches is freed. The
+ // runtime joins its tasks on exit, so a reader left parked in read(2) would
+ // hang the process instead of the app quitting.
+ for (0..pardes.MAX_PANES) |pane| reap(st, @intCast(pane));
+ // Only now is the inbox quiet. Anything still queued owns gpa bytes and
+ // would show up as a leak rather than as the shutdown it actually is.
+ for (st.inbox.items.items) |msg| msg.free(st.gpa);
+ st.inbox.items.deinit(st.gpa);
+ for (st.tick_msgs.items) |msg| msg.free(st.gpa);
+ st.tick_msgs.deinit(st.gpa);
+ st.cells.deinit(st.gpa);
+ st.core.deinit();
+ st.arena.deinit();
+ st.config_arena.deinit();
+ pardes.image.stop();
+ st.threaded.deinit();
+ st.gpa.destroy(st.threaded);
+ state = null;
+}
+
+export fn pardes_should_quit() bool {
+ const st = &(state orelse return true);
+ return st.core.quit;
+}
+
+export fn pardes_animating() bool {
+ const st = &(state orelse return false);
+ return st.core.themeAnimationActive();
+}
+
+/// Drain what the reader tasks collected into the core, then perform whatever
+/// the core queued in response. Returns whether anything moved, so an idle
+/// wakeup does not cost the host a repaint.
+export fn pardes_tick() bool {
+ const st = &(state orelse return false);
+ // Cleared BEFORE the swap: a reader that pushes while this drain is running
+ // must be able to schedule the tick that will collect it.
+ st.inbox.wake_pending.store(false, .release);
+ st.inbox.lock();
+ std.mem.swap(std.ArrayList(Msg), &st.inbox.items, &st.tick_msgs);
+ st.inbox.mutex.unlock();
+
+ var changed = st.tick_msgs.items.len > 0;
+ for (st.tick_msgs.items) |msg| {
+ defer msg.free(st.gpa);
+ switch (msg) {
+ .output => |o| {
+ if (st.gens[o.pane] != o.gen) continue;
+ st.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ },
+ .eof => |e| {
+ if (st.gens[e.pane] != e.gen) continue;
+ // The shell is gone: join its reader (a completed future that
+ // is never awaited leaks its allocation), close the master and
+ // free the slot. Without this the slot is only ever reaped by a
+ // later spawn INTO it — and the core emits spawn from newPane
+ // alone, so a pane that becomes a file pane instead would hold
+ // the dead fd for the life of the app.
+ reap(st, e.pane);
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
+ },
+ }
+ }
+ st.tick_msgs.clearRetainingCapacity();
+ if (drainEffects(st, true)) changed = true;
+ // A live theme transition repaints on its own clock; say so, or the host
+ // stops ticking and the fade freezes half-applied.
+ if (st.core.themeAnimationActive()) changed = true;
+ return changed;
+}
+
+// ---------------------------------------------------------------- events in
+
+export fn pardes_key(cp_arg: u32, text_ptr: ?[*]const u8, len: usize, mods: u32) void {
+ const st = &(state orelse return);
+ if (cp_arg > std.math.maxInt(u21)) return;
+ const text: []const u8 = if (text_ptr) |p| p[0..len] else "";
+ st.core.update(.{ .key = .{
+ .cp = @intCast(cp_arg),
+ .text = text,
+ .ctrl = mods & 1 != 0,
+ .alt = mods & 2 != 0,
+ .shift = mods & 4 != 0,
+ } });
+}
+
+export fn pardes_paste(text_ptr: ?[*]const u8, len: usize) void {
+ const st = &(state orelse return);
+ const text: []const u8 = if (text_ptr) |p| p[0..len] else "";
+ st.core.update(.{ .paste = text });
+}
+
+/// Button and kind arrive as their boundary ordinals. An out-of-range value is
+/// dropped rather than reaching an unchecked enum cast — same rule the browser
+/// ABI keeps, for the same reason: the host is not part of this build.
+export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, mods: u32) void {
+ const st = &(state orelse return);
+ const button: pardes.Mouse.Button = switch (button_arg) {
+ 0 => .left,
+ 1 => .middle,
+ 2 => .right,
+ 3 => .wheel_up,
+ 4 => .wheel_down,
+ 5 => .wheel_left,
+ 6 => .wheel_right,
+ 7 => .none,
+ else => return,
+ };
+ const kind: pardes.Mouse.Kind = switch (kind_arg) {
+ 0 => .press,
+ 1 => .release,
+ 2 => .motion,
+ 3 => .drag,
+ else => return,
+ };
+ st.core.update(.{ .mouse = .{
+ .button = button,
+ .kind = kind,
+ .col = col,
+ .row = row,
+ .ctrl = mods & 1 != 0,
+ } });
+}
+
+export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void {
+ const st = &(state orelse return);
+ const ticks = takeScrollTicks(&st.scroll_lag, delta_rows);
+ var left = ticks;
+ while (left != 0) {
+ const down = left > 0;
+ left += if (down) -1 else 1;
+ st.core.update(.{ .mouse = .{
+ .button = if (down) .wheel_down else .wheel_up,
+ .kind = .press,
+ .col = col,
+ .row = row,
+ } });
+ }
+}
+
+export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) void {
+ const st = &(state orelse return);
+ const cols = @max(1, cols_arg);
+ const rows = @max(1, rows_arg);
+ st.core.update(.{ .resize = .{
+ .cols = cols,
+ .rows = rows,
+ .cell_pixels = if (@hasField(pardes.CellPixels, "w"))
+ .{ .w = @max(1, cell_w), .h = @max(1, cell_h) }
+ else
+ .{},
+ } });
+}
+
+// ---------------------------------------------------------------- frame out
+
+export fn pardes_frame() u32 {
+ const st = &(state orelse return 0);
+ _ = st.arena.reset(.retain_capacity);
+ // The three accessors below must never describe a different frame than the
+ // count this returns, so a failure empties all of them together rather than
+ // leaving last frame's buffer behind a fresh cols/rows.
+ st.cells.clearRetainingCapacity();
+ st.frame_cols = 0;
+ st.frame_rows = 0;
+ const surface = st.core.render(st.arena.allocator()) catch |err| {
+ log.err("render failed: {t}", .{err});
+ return 0;
+ };
+ const count: usize = @as(usize, surface.cols) * surface.rows;
+ st.cells.resize(st.gpa, count) catch return 0;
+ st.frame_cols = surface.cols;
+ st.frame_rows = surface.rows;
+ for (surface.cells, st.cells.items) |cell, *out| {
+ out.* = .{
+ .text = @splat(0),
+ .fg = encodeColor(cell.style.fg),
+ .bg = encodeColor(cell.style.bg),
+ .attrs = encodeAttrs(cell.style),
+ .len = if (cell.default) 1 else cell.len,
+ .flags = @intFromBool(cell.default),
+ };
+ if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
+ }
+ return @intCast(count);
+}
+
+export fn pardes_frame_cells() ?[*]const Cell {
+ const st = &(state orelse return null);
+ return if (st.cells.items.len == 0) null else st.cells.items.ptr;
+}
+
+export fn pardes_frame_cols() u16 {
+ const st = &(state orelse return 0);
+ return st.frame_cols;
+}
+
+export fn pardes_frame_rows() u16 {
+ const st = &(state orelse return 0);
+ return st.frame_rows;
+}
+
+export fn pardes_cursor_x() i32 {
+ const st = &(state orelse return -1);
+ return if (st.core.surface.cursor) |c| c.x else -1;
+}
+
+export fn pardes_cursor_y() i32 {
+ const st = &(state orelse return -1);
+ return if (st.core.surface.cursor) |c| c.y else -1;
+}
+
+export fn pardes_cursor_bar() bool {
+ const st = &(state orelse return false);
+ return if (st.core.surface.cursor) |c| c.bar else false;
+}
+
+// ---------------------------------------------------------------- effects
+
+/// Perform the IO the core queued. `threads_ok` is false for the one drain
+/// inside pardes_init, which runs before any reader task exists.
+///
+/// ponytail: the lsp, pipe and watch effects are answered with nothing. Each
+/// wants real machinery — a worker plus a snapshot of the pane's file for lsp
+/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since
+/// inotify is Linux-only. The core is built to tolerate an unanswered effect:
+/// the browser answers none of these either. Lift tty.zig's implementations
+/// when the app is past first light.
+fn drainEffects(st: *State, threads_ok: bool) bool {
+ const core = st.core;
+ var did = false;
+ while (core.nextEffect()) |effect| {
+ did = true;
+ switch (effect) {
+ .spawn => |sp| {
+ // The core reuses pane ids and has no close effect, so a
+ // deleted pane's shell lives in its slot until a respawn lands
+ // here. Reap it: cancel joins the reader, and the generation
+ // bump makes its late bytes and eof unreadable.
+ reap(st, sp.pane);
+ st.gens[sp.pane] +%= 1;
+ const gen = st.gens[sp.pane];
+
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ // <= because writing the sentinel slot of a [N:0]u8 is legal,
+ // and Effect's cwd buffer is exactly 256: `<` would silently
+ // drop a maximal path and start the shell wherever the app
+ // bundle was launched from instead.
+ if (cwd.len > 0 and cwd.len <= cwd_buf.len) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ st.ptys[sp.pane] = .{
+ .file = child.file,
+ .pid = child.pid,
+ .gen = gen,
+ .reader = .{ .any_future = null, .result = {} },
+ };
+ // Report the pane's starting directory back to the core (tags).
+ var lbuf: [1024]u8 = undefined;
+ if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd);
+ if (threads_ok) if (st.ptys[sp.pane]) |*pt| startReader(st, pt, sp.pane);
+ },
+ .write => |w| {
+ if (st.ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (st.ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => |url| look.openLink(url.slice()),
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) continue;
+ writeFd(fd, f.content);
+ _ = libc.close(fd);
+ // After the write, not beside it: every `continue` above is a
+ // save that did not happen and must not be reported as one.
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(sf.pane, message.stamp(&mbuf, "saved", f.path));
+ },
+ .new_file => |request| {
+ var path_buf: [4096:0]u8 = undefined;
+ const made = temp_file.create(&path_buf) orelse continue;
+ if (core.openNewFile(request.pane, request.serial, made.path))
+ made.adopt()
+ else
+ made.discard();
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse continue;
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) continue;
+ writeFd(fd, out);
+ _ = libc.close(fd);
+ core.setLastDump(path);
+ },
+ .set_clipboard => {
+ const cb = st.runtime.set_clipboard orelse continue;
+ const y = core.yank orelse continue;
+ cb(st.runtime.userdata, y.ptr, y.len);
+ },
+ .lsp, .pipe, .watch => {},
+ .quit => {},
+ }
+ }
+ return did;
+}
+
+// ---------------------------------------------------------------- workers
+
+fn startReader(st: *State, pt: *Pty, id: u8) void {
+ pt.reader = st.io.concurrent(readPty, .{ st, st.io, pt.file, id, pt.gen }) catch |err| {
+ // No reader means the shell fills its pty buffer, blocks in write(2)
+ // and the pane silently freezes. Nothing recovers it, so at least say
+ // so — this is what PARDES_LOG exists for.
+ log.err("pane {d} has no reader ({t}); it will not show output", .{ id, err });
+ return;
+ };
+}
+
+/// Release one pane's shell: join the reader, close the master, reap the child.
+/// Order matters — cancel is what unblocks a task parked in read(2), and the fd
+/// must not be closed under a live reader. Called on eof and again on a spawn
+/// into the same slot, so it has to tolerate an empty slot.
+fn reap(st: *State, pane: u8) void {
+ var pt = st.ptys[pane] orelse return;
+ st.ptys[pane] = null;
+ pt.reader.cancel(st.io) catch {};
+ _ = libc.close(pt.file.handle);
+ // A library inside an app that runs for hours cannot leave these: the tty
+ // shell gets away with never reaping because the process exits seconds
+ // later, but here it would be one zombie per shell ever opened. NOHANG
+ // because the child may still be dying and the UI thread must not wait for
+ // it; the next reap or process exit collects whatever is left.
+ _ = libc.waitpid(pt.pid, null, posix.W.NOHANG);
+}
+
+/// Drain one pty into its inbox and wake the host. The same shape as the tty
+/// shell's reader, with the vaxis event queue replaced by a mutex and one
+/// callback: do the blocking thing away from the loop, hand the bytes over,
+/// leave the core a state machine that never waits.
+fn readPty(st: *State, io: std.Io, pty: std.Io.File, id: u8, gen: u32) anyerror!void {
+ var read_buf: [0x10000]u8 = undefined;
+ var reader = pty.readerStreaming(io, &read_buf);
+ while (true) {
+ var buf: [0x10000]u8 = undefined;
+ var vec = [_][]u8{&buf};
+ const n = reader.interface.readVec(&vec) catch break;
+ if (n == 0) break;
+ // Duped outside the lock on purpose — see Inbox.
+ const bytes = st.gpa.dupe(u8, buf[0..n]) catch break;
+ st.inbox.push(st.gpa, .{ .output = .{ .pane = id, .gen = gen, .bytes = bytes } });
+ wake(st);
+ }
+ st.inbox.push(st.gpa, .{ .eof = .{ .pane = id, .gen = gen } });
+ wake(st);
+}
+
+/// Ask the host for a tick, at most once per tick. `pardes_tick` clears the
+/// flag before it drains, so a push that lands mid-drain still wakes and no
+/// message can be left sitting in the inbox with nobody scheduled to read it.
+fn wake(st: *State) void {
+ const cb = st.runtime.wakeup orelse return;
+ if (st.inbox.wake_pending.swap(true, .acq_rel)) return;
+ cb(st.runtime.userdata);
+}
+
+// ---------------------------------------------------------------- helpers
+
+fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+ var master: c_int = undefined;
+ // Resolved BEFORE the fork, into this frame, which the child inherits:
+ // nothing between fork and exec may allocate, so a PATH search cannot
+ // happen there.
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const spawn = shell_bin.resolve(bin, &path_buf);
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ if (cwd) |c| _ = chdir(c);
+ _ = execv(spawn.path, &spawn.argv);
+ _exit(127);
+ }
+ return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const n = libc.write(fd, data[off..].ptr, data.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return;
+ }
+ // A zero-byte write makes no progress; looping on it would spin the
+ // main thread forever, which here means a beachball rather than the
+ // tty shell's hung terminal.
+ if (n == 0) return;
+ off += @intCast(n);
+ }
+}
+
+fn writeFile(path: [*:0]const u8, contents: []const u8) void {
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ defer _ = libc.close(fd);
+ writeFd(fd, contents);
+}
+
+/// Rebuild the process environment as a Map, because a library never sees the
+/// std.process.Init that main() gets one from. Only the config-path lookup
+/// reads it, and the arena owns the copies for the life of the process.
+fn captureEnv(arena: std.mem.Allocator) ?std.process.Environ.Map {
+ var map: std.process.Environ.Map = .init(arena);
+ const environ = std.c.environ;
+ var i: usize = 0;
+ while (environ[i]) |entry| : (i += 1) {
+ const line = std.mem.span(entry);
+ const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue;
+ map.put(line[0..eq], line[eq + 1 ..]) catch return null;
+ }
+ return map;
+}
+
+fn encodeColor(color: pardes.Color) u32 {
+ return switch (color) {
+ .default => color_default,
+ .index => |index| color_indexed | @as(u32, index),
+ .rgb => |rgb| (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | rgb[2],
+ };
+}
+
+fn encodeAttrs(style: pardes.CellStyle) u16 {
+ var attrs: u16 = 0;
+ attrs |= @as(u16, @intFromBool(style.bold)) << 0;
+ attrs |= @as(u16, @intFromBool(style.dim)) << 1;
+ attrs |= @as(u16, @intFromBool(style.italic)) << 2;
+ attrs |= @as(u16, @intFromBool(style.blink)) << 3;
+ attrs |= @as(u16, @intFromBool(style.reverse)) << 4;
+ attrs |= @as(u16, @intFromBool(style.invisible)) << 5;
+ attrs |= @as(u16, @intFromBool(style.strikethrough)) << 6;
+ attrs |= @as(u16, @intFromEnum(style.ul)) << 8;
+ return attrs;
+}
+
+/// Spend accumulated sub-row travel as whole wheel notches, keeping the
+/// remainder. The core has no fractional scroll — both other shells do this
+/// same accumulation host-side (stepScroll in gui.zig, the drain loop in
+/// web/app.mjs) — so it lives here and the Swift side stays a translator.
+///
+/// The lag is clamped to one screen's worth so a nonsense delta (an inertial
+/// fling reported in points, a NaN) cannot spin the emit loop.
+fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 {
+ if (!std.math.isFinite(delta_rows)) return 0;
+ const next = std.math.clamp(lag.* + delta_rows, -256, 256);
+ if (!std.math.isFinite(next)) return 0;
+ const whole: i32 = @intFromFloat(@trunc(next));
+ lag.* = next - @as(f32, @floatFromInt(whole));
+ return whole;
+}
+
+// ---------------------------------------------------------------- ABI guard
+
+// The header is hand-written, so nothing but a test keeps it honest. build.zig
+// translate-C's src/macos/pardes.h into this test build and every constant and
+// layout below is asserted against the Zig side — ghostty's trick, and the
+// cheapest possible insurance against a silent ABI skew.
+/// Compare one declaration's arity and scalar widths against the header's.
+/// Not a type equality — translate-C spells pointers `[*c]` and mints its own
+/// struct types, so nothing here would ever match exactly. Arity and width are
+/// what actually break: a parameter added on one side only (which is how the
+/// Swift host first got pardes_scroll wrong), or a u16 that became a u32.
+fn expectSameAbi(comptime C: type, comptime Z: type) !void {
+ const c_fn = @typeInfo(C).@"fn";
+ const z_fn = @typeInfo(Z).@"fn";
+ try std.testing.expectEqual(c_fn.params.len, z_fn.params.len);
+ inline for (c_fn.params, z_fn.params) |cp, zp|
+ try std.testing.expectEqual(@sizeOf(cp.type.?), @sizeOf(zp.type.?));
+ try std.testing.expectEqual(@sizeOf(c_fn.return_type.?), @sizeOf(z_fn.return_type.?));
+}
+
+test "pardes.h declares every export the way it is defined" {
+ const c = @import("pardes.h");
+ try expectSameAbi(@TypeOf(c.pardes_init), @TypeOf(pardes_init));
+ try expectSameAbi(@TypeOf(c.pardes_deinit), @TypeOf(pardes_deinit));
+ try expectSameAbi(@TypeOf(c.pardes_tick), @TypeOf(pardes_tick));
+ try expectSameAbi(@TypeOf(c.pardes_should_quit), @TypeOf(pardes_should_quit));
+ try expectSameAbi(@TypeOf(c.pardes_animating), @TypeOf(pardes_animating));
+ try expectSameAbi(@TypeOf(c.pardes_key), @TypeOf(pardes_key));
+ try expectSameAbi(@TypeOf(c.pardes_paste), @TypeOf(pardes_paste));
+ try expectSameAbi(@TypeOf(c.pardes_mouse), @TypeOf(pardes_mouse));
+ try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll));
+ try expectSameAbi(@TypeOf(c.pardes_resize), @TypeOf(pardes_resize));
+ try expectSameAbi(@TypeOf(c.pardes_frame), @TypeOf(pardes_frame));
+ try expectSameAbi(@TypeOf(c.pardes_frame_cells), @TypeOf(pardes_frame_cells));
+ try expectSameAbi(@TypeOf(c.pardes_frame_cols), @TypeOf(pardes_frame_cols));
+ try expectSameAbi(@TypeOf(c.pardes_frame_rows), @TypeOf(pardes_frame_rows));
+ try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x));
+ try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y));
+ try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar));
+}
+
+test "pardes.h matches the Zig boundary" {
+ const c = @import("pardes.h");
+ const expectEqual = std.testing.expectEqual;
+
+ try expectEqual(@sizeOf(c.pardes_cell_s), @sizeOf(Cell));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "text"), @offsetOf(Cell, "text"));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "fg"), @offsetOf(Cell, "fg"));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "bg"), @offsetOf(Cell, "bg"));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "attrs"), @offsetOf(Cell, "attrs"));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "len"), @offsetOf(Cell, "len"));
+ try expectEqual(@offsetOf(c.pardes_cell_s, "flags"), @offsetOf(Cell, "flags"));
+ try expectEqual(@sizeOf(c.pardes_runtime_s), @sizeOf(Runtime));
+
+ try expectEqual(@as(u32, c.PARDES_COLOR_DEFAULT), color_default);
+ try expectEqual(@as(u32, c.PARDES_COLOR_INDEXED), color_indexed);
+ try expectEqual(@as(u8, c.PARDES_CELL_DEFAULT), cell_flag_default);
+
+ // Every key the host has a name for must be the codepoint the core reads.
+ try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter);
+ try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape);
+ try expectEqual(@as(u21, c.PARDES_KEY_TAB), pardes.Key.tab);
+ try expectEqual(@as(u21, c.PARDES_KEY_BACKSPACE), pardes.Key.backspace);
+ try expectEqual(@as(u21, c.PARDES_KEY_UP), pardes.Key.up);
+ try expectEqual(@as(u21, c.PARDES_KEY_DOWN), pardes.Key.down);
+ try expectEqual(@as(u21, c.PARDES_KEY_LEFT), pardes.Key.left);
+ try expectEqual(@as(u21, c.PARDES_KEY_RIGHT), pardes.Key.right);
+ try expectEqual(@as(u21, c.PARDES_KEY_HOME), pardes.Key.home);
+ try expectEqual(@as(u21, c.PARDES_KEY_END), pardes.Key.end);
+ try expectEqual(@as(u21, c.PARDES_KEY_PAGE_UP), pardes.Key.page_up);
+ try expectEqual(@as(u21, c.PARDES_KEY_PAGE_DOWN), pardes.Key.page_down);
+ try expectEqual(@as(u21, c.PARDES_KEY_DELETE), pardes.Key.delete);
+
+ // The mouse ordinals the switch in pardes_mouse decodes are the enum's own
+ // declaration order; a reorder there is a silent remap of acme's buttons.
+ try expectEqual(c.PARDES_MOUSE_LEFT, @intFromEnum(pardes.Mouse.Button.left));
+ try expectEqual(c.PARDES_MOUSE_MIDDLE, @intFromEnum(pardes.Mouse.Button.middle));
+ try expectEqual(c.PARDES_MOUSE_RIGHT, @intFromEnum(pardes.Mouse.Button.right));
+ try expectEqual(c.PARDES_MOUSE_WHEEL_UP, @intFromEnum(pardes.Mouse.Button.wheel_up));
+ try expectEqual(c.PARDES_MOUSE_WHEEL_DOWN, @intFromEnum(pardes.Mouse.Button.wheel_down));
+ try expectEqual(c.PARDES_MOUSE_WHEEL_LEFT, @intFromEnum(pardes.Mouse.Button.wheel_left));
+ try expectEqual(c.PARDES_MOUSE_WHEEL_RIGHT, @intFromEnum(pardes.Mouse.Button.wheel_right));
+ try expectEqual(c.PARDES_MOUSE_NONE, @intFromEnum(pardes.Mouse.Button.none));
+ try expectEqual(c.PARDES_MOUSE_PRESS, @intFromEnum(pardes.Mouse.Kind.press));
+ try expectEqual(c.PARDES_MOUSE_RELEASE, @intFromEnum(pardes.Mouse.Kind.release));
+ try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion));
+ try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag));
+
+ // The attribute bits the host decodes, against the encoder that writes them.
+ try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_ITALIC), encodeAttrs(.{ .italic = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_BLINK), encodeAttrs(.{ .blink = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_REVERSE), encodeAttrs(.{ .reverse = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_INVISIBLE), encodeAttrs(.{ .invisible = true }));
+ try expectEqual(@as(u16, c.PARDES_ATTR_STRIKETHROUGH), encodeAttrs(.{ .strikethrough = true }));
+ try expectEqual(
+ @as(u16, c.PARDES_UL_CURLY) << c.PARDES_ATTR_UL_SHIFT,
+ encodeAttrs(.{ .ul = .curly }),
+ );
+}
+
+test "colors encode to the three tags the host decodes" {
+ const expectEqual = std.testing.expectEqual;
+ try expectEqual(@as(u32, 0x01000000), encodeColor(.default));
+ try expectEqual(@as(u32, 0x02000021), encodeColor(.{ .index = 33 }));
+ try expectEqual(@as(u32, 0x00112233), encodeColor(.{ .rgb = .{ 0x11, 0x22, 0x33 } }));
+}
+
+test "sub-row scroll spends whole notches and keeps the remainder" {
+ const expectEqual = std.testing.expectEqual;
+ var lag: f32 = 0;
+ // Four quarter-row flicks are one row, and not before the fourth.
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25));
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25));
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25));
+ try expectEqual(@as(i32, 1), takeScrollTicks(&lag, 0.25));
+ try expectEqual(@as(f32, 0), lag);
+
+ // Direction reverses without the accumulated travel leaking across it.
+ try expectEqual(@as(i32, -2), takeScrollTicks(&lag, -2.5));
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, 0.25));
+
+ // Garbage moves nothing and leaves the accumulator usable; a fling far
+ // past the clamp spends at most one screen and does not spin the caller.
+ lag = 0;
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.nan(f32)));
+ try expectEqual(@as(i32, 0), takeScrollTicks(&lag, std.math.inf(f32)));
+ try expectEqual(@as(f32, 0), lag);
+ try expectEqual(@as(i32, 256), takeScrollTicks(&lag, 1e9));
+}
diff --git a/src/macos/Info.plist b/src/macos/Info.plist
new file mode 100644
index 00000000..9e8f65f7
--- /dev/null
+++ b/src/macos/Info.plist
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleName</key>
+ <string>pardes</string>
+ <key>CFBundleIdentifier</key>
+ <string>cafe.0x4200.pardes</string>
+ <key>CFBundleExecutable</key>
+ <string>pardes</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>NSHighResolutionCapable</key>
+ <true/>
+ <key>LSMinimumSystemVersion</key>
+ <string>13.0</string>
+ <key>NSPrincipalClass</key>
+ <string>NSApplication</string>
+</dict>
+</plist>
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
new file mode 100644
index 00000000..4df5899c
--- /dev/null
+++ b/src/macos/Sources/AppDelegate.swift
@@ -0,0 +1,179 @@
+import AppKit
+
+// The macOS host: one window, one view, one core. libpardes owns the state
+// machine, the ptys and every worker thread; this file owns the window and the
+// pump that lets the core move at all.
+//
+// PardesView translates events and calls pardes_key / pardes_mouse /
+// pardes_scroll itself, but never pardes_tick — the core only queues what it
+// was told and does nothing until it is pumped. Rather than grow the view's
+// delegate a third method for "I just fed the core", the view posts this
+// notification after every input call and we answer it with pump(). The string
+// below is the whole contract with PardesView.swift; keep the two in step.
+private let didInputNotification = Notification.Name("pardesDidInput")
+
+final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
+ private var window: NSWindow!
+ private var view: PardesView!
+ // One pump chain at a time. pump() re-arms itself while a theme transition
+ // is in flight and every input pumps as well, so without this a burst of
+ // keys during a fade would leave one 60 Hz chain per keystroke, all of them
+ // ticking until the fade ended.
+ private var pumpScheduled: Bool = false
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl-
+ // (gui.zig); doing that here means re-measuring the view's metrics and
+ // pushing a resize behind it, so it waits until the font has to move.
+ view = PardesView(fontSize: 14)
+
+ let want = NSSize(width: 1000, height: 700)
+ window = NSWindow(
+ contentRect: NSRect(origin: .zero, size: want),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false)
+ window.title = "pardes"
+ // We hold this window strongly for the life of the app. Leaving
+ // isReleasedWhenClosed on would have AppKit release it out from under
+ // that reference the moment the close button is pressed.
+ window.isReleasedWhenClosed = false
+ window.contentView = view
+
+ // Trim the content box down to whole cells: a partial column or row is
+ // dead space the core can never draw into.
+ //
+ // ponytail: snapped once, at launch — a live drag lands wherever the
+ // mouse lets go. window.contentResizeIncrements would snap every
+ // resize, at the price of arguing with macOS full-screen tiling.
+ window.setContentSize(NSSize(
+ width: (want.width / view.cellWidth).rounded(.down) * view.cellWidth,
+ height: (want.height / view.cellHeight).rounded(.down) * view.cellHeight))
+ window.center()
+ // On screen before pardes_init, so that the backingScaleFactor read
+ // when seeding the cell metrics below is the one of the screen the
+ // window actually landed on. Drawing before the core exists costs an
+ // empty frame; metrics seeded at 1x would stay wrong forever, because
+ // a scale change moves no bounds and so fires no resize.
+ window.makeKeyAndOrderFront(nil)
+ // AppKit may promote a content view that accepts first responder on its
+ // own, but "may" is not something to bet every keystroke on, and there
+ // is no click-to-focus path here that would recover it.
+ window.makeFirstResponder(view)
+ // ponytail: no main menu, so no Cmd+Q — the close button and the core's
+ // Exit builtin are the two ways out. An NSMenu is ten lines, but the
+ // moment one exists it also has to decide which Cmd keys the core is
+ // allowed to see, and that is a real decision, not boilerplate.
+ //
+ // UNVERIFIED: activate(ignoringOtherApps:) is deprecated on the 14 SDK
+ // in favour of activate(), which does not exist at our 13.0 deployment
+ // target. Expect a deprecation warning, not an error.
+ NSApp.activate(ignoringOtherApps: true)
+
+ var runtime = pardes_runtime_s(
+ userdata: Unmanaged.passUnretained(self).toOpaque(),
+ wakeup: { ud in
+ // A pty reader thread. Every other function in pardes.h is
+ // main-thread-only, so this hop is the entire body — touching
+ // any core state here would be the race the hop exists to
+ // avoid.
+ let host = Unmanaged<AppDelegate>.fromOpaque(ud!).takeUnretainedValue()
+ DispatchQueue.main.async { host.pump() }
+ },
+ set_clipboard: { _, text, len in
+ // Main thread, from inside pardes_tick. `text` is borrowed for
+ // the length of the call, so the String has to be a copy.
+ var yank: String = ""
+ if let text = text, len > 0 {
+ let bytes = UnsafeRawBufferPointer(start: UnsafeRawPointer(text), count: len)
+ yank = String(decoding: bytes, as: UTF8.self)
+ }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ _ = pasteboard.setString(yank, forType: .string)
+ })
+
+ // The real grid, never a placeholder: the core holds each shell's
+ // greeting until it has seen a size, so a correction sent afterwards
+ // arrives with the first prompt already wrapped to the wrong width.
+ let grid = view.gridSize
+ let rc = pardes_init(&runtime, grid.cols, grid.rows)
+ if rc != 0 {
+ NSLog("pardes: the core failed to boot at \(grid.cols)x\(grid.rows), pardes_init returned \(rc)")
+ // Not NSApp.terminate — that runs applicationWillTerminate, which
+ // would call pardes_deinit on a core that never came up.
+ exit(1)
+ }
+
+ // Only now, after init. setContentSize above resized the view, and a
+ // pardesViewDidResize landing before pardes_init would have pushed a
+ // resize into a core that did not exist yet.
+ view.delegate = self
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(inputArrived(_:)), name: didInputNotification, object: nil)
+
+ // pardes_init takes no cell metrics, so the core's PDF placement would
+ // have none until the user first dragged the window. This seeds them,
+ // and pumps. Repeating the size init just saw is safe by design: the
+ // core compares the effective pixel viewport, not the resize event, so
+ // duplicate SIGWINCH-shaped notifications are already a no-op.
+ pardesViewDidResize(view)
+ }
+
+ @objc private func inputArrived(_ notification: Notification) {
+ pump()
+ }
+
+ // Every path into the core ends here. pardes_tick drains the ptys and runs
+ // the effects the core queued, so nothing the user did takes hold until it
+ // runs.
+ private func pump() {
+ if pardes_tick() { view.needsDisplay = true }
+ if pardes_should_quit() {
+ NSApp.terminate(nil)
+ return
+ }
+ // A theme transition is the only thing that moves on its own, and it is
+ // ten 16 ms steps (animation.zig). Nothing else re-arms this, which is
+ // the point of an event-driven host: an idle pardes costs no CPU, where
+ // the SDL shell spins.
+ if pardes_animating() && !pumpScheduled {
+ pumpScheduled = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.016) {
+ self.pumpScheduled = false
+ self.pump()
+ }
+ }
+ }
+
+ func pardesViewDidResize(_ view: PardesView) {
+ let grid = view.gridSize
+ // cell_w/cell_h are physical pixels. The core hands them to the native
+ // PDF placement path, and a point is two pixels on a Retina display, so
+ // passing points would place every page at half size.
+ let scale: CGFloat = window.backingScaleFactor
+ pardes_resize(
+ grid.cols, grid.rows,
+ UInt16((view.cellWidth * scale).rounded()),
+ UInt16((view.cellHeight * scale).rounded()))
+ pump()
+ }
+
+ func pardesViewRequestsPaste(_ view: PardesView) {
+ guard let text = NSPasteboard.general.string(forType: .string) else { return }
+ // Swift lends a temporary NUL-terminated UTF-8 buffer for the duration
+ // of the call, which is exactly as long as the core borrows it.
+ pardes_paste(text, text.utf8.count)
+ pump()
+ }
+
+ // ponytail: one window, no tabs — the core has no multi-window notion, so
+ // the last window closing really is the end of the process.
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return true
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ pardes_deinit()
+ }
+}
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
new file mode 100644
index 00000000..2dfa4305
--- /dev/null
+++ b/src/macos/Sources/PardesView.swift
@@ -0,0 +1,478 @@
+// The AppKit half of the macOS backend: NSEvent in, pardes_* out, one
+// CoreGraphics pass per frame back.
+//
+// This file keeps no model of the screen. The core owns the grid and hands it
+// over whole through src/macos/pardes.h, so everything here is translation, and
+// the only state worth holding is the font metrics — which are expensive to
+// measure and never change.
+//
+// Every C constant below is wrapped in an explicit conversion (UInt16(...),
+// UInt32(...)) rather than used bare. A macro's imported Swift type is decided
+// by the importer, not by us, and this file should not have an opinion about it.
+
+import AppKit
+import CoreText
+
+protocol PardesViewDelegate: AnyObject {
+ func pardesViewDidResize(_ view: PardesView)
+ func pardesViewRequestsPaste(_ view: PardesView)
+}
+
+// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in
+// src/web/app.mjs. Three shells render the same core; if these drift, comparing
+// a screenshot across backends stops meaning anything.
+private let defaultFG: UInt32 = 0xCC_CC_CC
+private let defaultBG: UInt32 = 0x12_12_12
+
+private let inputNotification = Notification.Name("pardesDidInput")
+
+// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is
+// the same string as .font, but spelling the CoreText key means the value stays
+// a CTFont instead of being bridged to NSFont on the way in.
+private let fontAttribute = NSAttributedString.Key(kCTFontAttributeName as String)
+
+// The named sixteen, then xterm's 6x6x6 cube, then the 24-step grey ramp. These
+// sixteen are app.mjs's, not gui.zig's: gui.zig borrows ghostty's palette
+// because it already links it, and the two disagree on the base colors.
+private let ansi16: [UInt32] = [
+ 0x00_00_00, 0xCC_00_00, 0x4E_9A_06, 0xC4_A0_00,
+ 0x34_65_A4, 0x75_50_7B, 0x06_98_9A, 0xD3_D7_CF,
+ 0x55_57_53, 0xEF_29_29, 0x8A_E2_34, 0xFC_E9_4F,
+ 0x72_9F_CF, 0xAD_7F_A8, 0x34_E2_E2, 0xEE_EE_EC,
+]
+
+private func paletteColor(_ index: UInt8) -> UInt32 {
+ if index < 16 { return ansi16[Int(index)] }
+ if index >= 232 {
+ let grey = UInt32(8 + (Int(index) - 232) * 10)
+ return grey << 16 | grey << 8 | grey
+ }
+ let n = Int(index) - 16
+ func level(_ part: Int) -> UInt32 { part == 0 ? 0 : UInt32(55 + part * 40) }
+ return level(n / 36) << 16 | level(n / 6 % 6) << 8 | level(n % 6)
+}
+
+private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 {
+ if encoded == UInt32(PARDES_COLOR_DEFAULT) { return fallback }
+ if encoded & UInt32(PARDES_COLOR_TAG_MASK) == UInt32(PARDES_COLOR_INDEXED) {
+ return paletteColor(UInt8(encoded & 0xFF))
+ }
+ return encoded & UInt32(PARDES_COLOR_RGB_MASK)
+}
+
+/// `block` means the filled cursor sits on this cell.
+private func resolve(
+ _ cell: pardes_cell_s,
+ block: Bool
+) -> (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool) {
+ // The core never painted this cell, which is most of the screen most of the
+ // time, so this branch is the one that has to stay cheap.
+ if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 {
+ return block ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false)
+ }
+ var fg = decodeColor(cell.fg, defaultFG)
+ var bg = decodeColor(cell.bg, defaultBG)
+ // The block cursor is a second reverse, so a cell that is already reversed
+ // cancels back to normal underneath it. Same rule as emitInstance in
+ // src/gui/gui.zig; the two must not drift.
+ var reverse = block
+ if cell.attrs & UInt16(PARDES_ATTR_REVERSE) != 0 { reverse = !reverse }
+ if reverse { swap(&fg, &bg) }
+ // ponytail: PARDES_ATTR_BLINK is decoded into nothing. Honouring it costs a
+ // timer plus a repaint budget for a bit nothing in pardes emits today; drive
+ // setNeedsDisplay from an NSTimer here when something does.
+ let visible = cell.attrs & UInt16(PARDES_ATTR_INVISIBLE) == 0 && cell.len > 0
+ // The other shells scale the channels by 6/10. Over a dark background alpha
+ // lands in the same place and costs one blend instead of three multiplies.
+ let alpha: CGFloat = cell.attrs & UInt16(PARDES_ATTR_DIM) != 0 ? 0.6 : 1
+ return (fg, bg, alpha, visible)
+}
+
+private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat {
+ var input = character
+ var glyph = CGGlyph(0)
+ guard CTFontGetGlyphsForCharacters(font, &input, &glyph, 1) else { return 0 }
+ var size = CGSize.zero
+ CTFontGetAdvancesForGlyphs(font, .horizontal, &glyph, &size, 1)
+ return size.width
+}
+
+private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 {
+ var mods: UInt32 = 0
+ if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) }
+ if flags.contains(.option) { mods |= UInt32(PARDES_MOD_ALT) }
+ if flags.contains(.shift) { mods |= UInt32(PARDES_MOD_SHIFT) }
+ return mods
+}
+
+final class PardesView: NSView {
+ let cellWidth: CGFloat
+ let cellHeight: CGFloat
+ weak var delegate: PardesViewDelegate?
+
+ private let regular: CTFont
+ private let bold: CTFont
+ private let italic: CTFont
+ private let boldItalic: CTFont
+ private let ascent: CGFloat
+ private let ruleThickness: CGFloat
+ private let underlineOffset: CGFloat
+ private var reportedCols: UInt16 = 0
+ private var reportedRows: UInt16 = 0
+
+ init(fontSize: CGFloat) {
+ let system = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
+ var face = CTFontCreateWithName(system.fontName as CFString, fontSize, nil)
+ // The whole layout is a fixed grid, so a proportional face is not a
+ // cosmetic problem, it is a broken screen. Resolving the system monospace
+ // font by name is a lookup that can miss; "M" and "i" disagreeing on
+ // advance is the cheapest possible proof that it did.
+ let em = advance(face, 0x4D)
+ if em <= 0 || abs(em - advance(face, 0x69)) > 0.01 {
+ face = CTFontCreateWithName("Menlo" as CFString, fontSize, nil)
+ }
+
+ // UNVERIFIED: CTFontSymbolicTraits member spelling (.traitBold/.traitItalic).
+ // A face with no italic cut returns nil here, hence the fallback to `face`.
+ func variant(_ traits: CTFontSymbolicTraits) -> CTFont {
+ CTFontCreateCopyWithSymbolicTraits(face, fontSize, nil, traits, traits) ?? face
+ }
+ regular = face
+ bold = variant(.traitBold)
+ italic = variant(.traitItalic)
+ boldItalic = variant([.traitBold, .traitItalic])
+
+ cellWidth = max(1, advance(face, 0x4D))
+ ascent = CTFontGetAscent(face)
+ cellHeight = max(1, (ascent + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
+ ruleThickness = max(1, CTFontGetUnderlineThickness(face))
+ underlineOffset = CTFontGetUnderlinePosition(face)
+
+ // 80x24 only so the window has a size to open at; the AppDelegate reads
+ // gridSize back and boots the core with whatever it actually got.
+ super.init(frame: NSRect(x: 0, y: 0, width: cellWidth * 80, height: cellHeight * 24))
+ }
+
+ required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") }
+
+ // Row 0 at the top, so the drawing arithmetic reads like the grid it is.
+ override var isFlipped: Bool { true }
+ override var isOpaque: Bool { true }
+ override var acceptsFirstResponder: Bool { true }
+
+ var gridSize: (cols: UInt16, rows: UInt16) {
+ let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max))
+ let rows = min(max((bounds.height / cellHeight).rounded(.down), 1), CGFloat(UInt16.max))
+ return (UInt16(cols), UInt16(rows))
+ }
+
+ // MARK: - drawing
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
+ // ponytail: dirtyRect is ignored. pardes_frame() re-renders the whole grid
+ // whatever we do, so clipping would save fills and nothing else. Narrow
+ // the row loops to the dirty band if that ever shows up in a profile.
+ let count = pardes_frame()
+ let cols = Int(pardes_frame_cols())
+ let rows = Int(pardes_frame_rows())
+ fill(ctx, bounds, defaultBG, 1)
+ guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { return }
+
+ // -1 when hidden, which never matches a real cell, so hidden and "bar, so
+ // not a block" collapse into the same comparison.
+ let bar = pardes_cursor_bar()
+ let blockX = bar ? -1 : Int(pardes_cursor_x())
+ let blockY = bar ? -1 : Int(pardes_cursor_y())
+
+ // Background first, batched into runs of equal color. A full redraw is
+ // 80x24 cells at the low end and per-cell fills are exactly what makes
+ // that feel slow. Antialiasing is off because touching rects share an
+ // edge, and blending that edge twice draws a visible seam.
+ ctx.setShouldAntialias(false)
+ for row in 0..<rows {
+ let base = row * cols
+ let y = CGFloat(row) * cellHeight
+ var start = 0
+ var color = resolve(cells[base], block: blockY == row && blockX == 0).bg
+ for col in 1...cols {
+ // A real color is 24 bits, so .max is a sentinel that cannot
+ // compare equal and therefore always flushes the last run.
+ let next: UInt32 = col == cols
+ ? .max
+ : resolve(cells[base + col], block: blockY == row && blockX == col).bg
+ if next == color { continue }
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
+ width: CGFloat(col - start) * cellWidth, height: cellHeight),
+ color, 1)
+ start = col
+ color = next
+ }
+ }
+
+ // CTFontDrawGlyphs lays glyph outlines out with +y up, and isFlipped hands
+ // us a y-down CTM, so drawing text directly in view space renders every
+ // line mirrored. Un-flip once for the whole glyph pass and convert each
+ // baseline into it rather than fighting the text matrix per cell.
+ ctx.setShouldAntialias(true)
+ ctx.saveGState()
+ ctx.textMatrix = .identity
+ ctx.translateBy(x: 0, y: bounds.height)
+ ctx.scaleBy(x: 1, y: -1)
+ let height = bounds.height
+ for row in 0..<rows {
+ let base = row * cols
+ let baseline = height - (CGFloat(row) * cellHeight + ascent)
+ for col in 0..<cols {
+ let cell = cells[base + col]
+ if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
+ let style = resolve(cell, block: blockY == row && blockX == col)
+ drawCell(ctx, cell, style, x: CGFloat(col) * cellWidth, baseline: baseline)
+ }
+ }
+ ctx.restoreGState()
+
+ if bar {
+ let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y())
+ if x >= 0, y >= 0, x < cols, y < rows {
+ // gui.zig paints U+258F here. A rect is the same picture without
+ // asking the font for a glyph it may not carry.
+ let fg = resolve(cells[y * cols + x], block: false).fg
+ fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight,
+ width: max(1, cellWidth / 8), height: cellHeight), fg, 1)
+ }
+ }
+ }
+
+ private func drawCell(
+ _ ctx: CGContext,
+ _ cell: pardes_cell_s,
+ _ style: (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool),
+ x: CGFloat,
+ baseline: CGFloat
+ ) {
+ // Rules before the glyph, and independent of it: an underlined space is a
+ // real thing and so is an underlined invisible cell.
+ let underline = Int(cell.attrs >> PARDES_ATTR_UL_SHIFT) & 7
+ if underline != Int(PARDES_UL_OFF) {
+ let y = baseline + underlineOffset
+ fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: ruleThickness), style.fg, style.alpha)
+ // ponytail: curly, dotted and dashed all come out solid; only double
+ // earns its second rule. ctx.setLineDash for two of them and a sine
+ // path for the third is the upgrade, once anyone notices.
+ if underline == Int(PARDES_UL_DOUBLE) {
+ fill(ctx, CGRect(x: x, y: y - ruleThickness * 2, width: cellWidth, height: ruleThickness),
+ style.fg, style.alpha)
+ }
+ }
+ if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
+ fill(ctx, CGRect(x: x, y: baseline + ascent * 0.3, width: cellWidth, height: ruleThickness),
+ style.fg, style.alpha)
+ }
+ guard style.visible else { return }
+
+ // UNVERIFIED: withUnsafeBytes over an imported C fixed-size array, which
+ // Swift models as an 8-tuple. String(decoding:) substitutes U+FFFD rather
+ // than trapping, and the core has shipped invalid UTF-8 through here
+ // before — the renderer must not be the thing that dies over it. prefix
+ // clamps, so a bogus len cannot walk off the eight bytes either.
+ let text = withUnsafeBytes(of: cell.text) { raw in
+ String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self)
+ }
+ guard !text.isEmpty, text != " " else { return }
+
+ let heavy = cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0
+ let slanted = cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0
+ let font = heavy ? (slanted ? boldItalic : bold) : (slanted ? italic : regular)
+ setFill(ctx, style.fg, style.alpha)
+
+ // ponytail: no glyph cache — a cmap lookup per cell, and a whole CTLine
+ // for anything that is not one BMP scalar the face covers. A
+ // [UnicodeScalar: CGGlyph] map, then an atlas, is the upgrade path when a
+ // full redraw shows up in Instruments.
+ let units = text.utf16
+ if units.count == 1, var character = units.first {
+ var glyph = CGGlyph(0)
+ if CTFontGetGlyphsForCharacters(font, &character, &glyph, 1) {
+ var position = CGPoint(x: x, y: baseline)
+ CTFontDrawGlyphs(font, &glyph, &position, 1, ctx)
+ return
+ }
+ }
+ // Emoji, combining marks and anything the face is missing: CTLine finds a
+ // fallback font. The position is set explicitly per cell — this is a fixed
+ // grid, and letting CoreText advance across a row would drift off it.
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: font])
+ ctx.textPosition = CGPoint(x: x, y: baseline)
+ CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx)
+ }
+
+ private func setFill(_ ctx: CGContext, _ rgb: UInt32, _ alpha: CGFloat) {
+ ctx.setFillColor(red: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: alpha)
+ }
+
+ private func fill(_ ctx: CGContext, _ rect: CGRect, _ rgb: UInt32, _ alpha: CGFloat) {
+ setFill(ctx, rgb, alpha)
+ ctx.fill(rect)
+ }
+
+ // MARK: - keyboard
+
+ override func keyDown(with event: NSEvent) {
+ let flags = event.modifierFlags
+ // The ABI has no super bit, so a Command chord cannot be expressed at all.
+ // Cmd-V is the paste the delegate owns; every other one is swallowed
+ // rather than delivered as the bare keystroke the core would insert.
+ if flags.contains(.command) {
+ if event.charactersIgnoringModifiers?.lowercased() == "v" {
+ delegate?.pardesViewRequestsPaste(self)
+ }
+ return
+ }
+
+ let control = flags.contains(.control)
+ let option = flags.contains(.option)
+ // With ctrl or option down, `characters` is already the composed result —
+ // Ctrl-A is U+0001, Option-A is "å". The core wants the base key and no
+ // text, which is what app.mjs does with the same two bits.
+ let composed = (control || option ? event.charactersIgnoringModifiers : event.characters) ?? ""
+ guard let scalar = composed.unicodeScalars.first else { return }
+
+ var codepoint = scalar.value
+ // 0xF700..0xF8FF is AppKit's private-use block for function keys. The nine
+ // the core names get translated; the rest (F1-F12, Insert, the keypad) are
+ // dropped, because passing one through paints a stray glyph.
+ // These constants come from an unnamed C enum, so which width Swift picks
+ // for them is not something this file should depend on: wrapping each in
+ // UInt32() compiles whether they import as Int, Int32 or UInt32.
+ if scalar.value >= 0xF700 && scalar.value <= 0xF8FF {
+ switch scalar.value {
+ case UInt32(NSUpArrowFunctionKey): codepoint = UInt32(PARDES_KEY_UP)
+ case UInt32(NSDownArrowFunctionKey): codepoint = UInt32(PARDES_KEY_DOWN)
+ case UInt32(NSLeftArrowFunctionKey): codepoint = UInt32(PARDES_KEY_LEFT)
+ case UInt32(NSRightArrowFunctionKey): codepoint = UInt32(PARDES_KEY_RIGHT)
+ case UInt32(NSHomeFunctionKey): codepoint = UInt32(PARDES_KEY_HOME)
+ case UInt32(NSEndFunctionKey): codepoint = UInt32(PARDES_KEY_END)
+ case UInt32(NSPageUpFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_UP)
+ case UInt32(NSPageDownFunctionKey): codepoint = UInt32(PARDES_KEY_PAGE_DOWN)
+ case UInt32(NSDeleteFunctionKey): codepoint = UInt32(PARDES_KEY_DELETE)
+ default: return
+ }
+ }
+
+ // Enter, Tab, Escape and Backspace already arrive as the ASCII controls the
+ // header names. Two keys do not: the keypad's Enter is U+0003 and Shift-Tab
+ // is U+0019, neither of which is in the function-key block above, so without
+ // this both reach the core as a control it has no binding for and do
+ // nothing. The browser shell resolves them from the DOM key name and this
+ // is what keeps the two hosts saying the same thing.
+ if codepoint == 0x03 { codepoint = UInt32(PARDES_KEY_ENTER) }
+ if codepoint == 0x19 { codepoint = UInt32(PARDES_KEY_TAB) }
+
+ // A control character is functional, and functional keys must not also
+ // carry text.
+ let functional = codepoint < 0x20 || codepoint == 0x7F || codepoint >= 0xF0000
+ let text = functional || control || option ? "" : composed
+ // The pointer is borrowed for the call and nowhere else, which is the only
+ // thing the header promises about it.
+ text.withCString { pardes_key(codepoint, $0, text.utf8.count, modifiers(flags)) }
+ NotificationCenter.default.post(name: inputNotification, object: self)
+ }
+
+ // MARK: - mouse
+
+ override func mouseDown(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_PRESS, event) }
+ override func mouseUp(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_RELEASE, event) }
+ override func mouseDragged(with event: NSEvent) { send(PARDES_MOUSE_LEFT, PARDES_MOUSE_DRAG, event) }
+ override func rightMouseDown(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_PRESS, event) }
+ override func rightMouseUp(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_RELEASE, event) }
+ override func rightMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_RIGHT, PARDES_MOUSE_DRAG, event) }
+ // otherMouse covers button 2 and up. acme's vocabulary stops at three, so
+ // every one of them lands on middle rather than being invented into a fourth.
+ override func otherMouseDown(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_PRESS, event) }
+ override func otherMouseUp(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_RELEASE, event) }
+ override func otherMouseDragged(with event: NSEvent) { send(PARDES_MOUSE_MIDDLE, PARDES_MOUSE_DRAG, event) }
+ override func mouseMoved(with event: NSEvent) { send(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, event) }
+
+ override func scrollWheel(with event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ if event.hasPreciseScrollingDeltas {
+ // The core scrolls a row at a time, so libpardes accumulates the
+ // sub-row travel and spends it as wheel presses — which is why the
+ // cell has to travel with the delta.
+ pardes_scroll(Float(-event.scrollingDeltaY / cellHeight), at.col, at.row)
+ } else if event.scrollingDeltaY != 0 {
+ // AppKit's sign is the opposite of the DOM's: positive deltaY means the
+ // content moved down, which is a scroll back through history.
+ let button = event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN
+ pardes_mouse(button, PARDES_MOUSE_PRESS, at.col, at.row, modifiers(event.modifierFlags))
+ } else {
+ return
+ }
+ NotificationCenter.default.post(name: inputNotification, object: self)
+ }
+
+ private func send(_ button: pardes_mouse_button_e, _ kind: pardes_mouse_kind_e, _ event: NSEvent) {
+ guard let at = cell(for: event) else { return }
+ pardes_mouse(button, kind, at.col, at.row, modifiers(event.modifierFlags))
+ NotificationCenter.default.post(name: inputNotification, object: self)
+ }
+
+ private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? {
+ // Clamp against the frame the core last rendered, not against our own
+ // metrics: a window that has been resized but not yet ticked would
+ // otherwise report a column the core has no cell for. Before the first
+ // frame there is no grid to point at and the event is meaningless.
+ let cols = Int(pardes_frame_cols())
+ let rows = Int(pardes_frame_rows())
+ guard cols > 0, rows > 0 else { return nil }
+ let point = convert(event.locationInWindow, from: nil)
+ let col = min(max(Int(point.x / cellWidth), 0), cols - 1)
+ let row = min(max(Int(point.y / cellHeight), 0), rows - 1)
+ return (UInt16(col), UInt16(row))
+ }
+
+ // MARK: - geometry
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ for area in trackingAreas { removeTrackingArea(area) }
+ // .inVisibleRect keeps the area correct across resizes on its own, which
+ // is why the rect argument can be anything.
+ addTrackingArea(NSTrackingArea(rect: .zero,
+ options: [.mouseMoved, .inVisibleRect, .activeInKeyWindow],
+ owner: self,
+ userInfo: nil))
+ }
+
+ override func setFrameSize(_ newSize: NSSize) {
+ super.setFrameSize(newSize)
+ // AppKit resizes a view many times over one drag and almost all of those
+ // land inside the same cell. Only a changed grid is news, and the core
+ // reflows every pty on a resize, so the no-ops are not free.
+ let grid = gridSize
+ guard grid.cols != reportedCols || grid.rows != reportedRows else { return }
+ reportedCols = grid.cols
+ reportedRows = grid.rows
+ delegate?.pardesViewDidResize(self)
+ }
+
+ // Dragging the window between a Retina display and a 1x one changes the
+ // backing scale without moving a single bound, so setFrameSize above never
+ // fires and the physical cell metrics the core uses to place PDF pages stay
+ // at the old scale forever. This is the only notification of it. (Ghostty
+ // hooks the same one, and additionally re-fires from the window's
+ // didChangeScreen notification, which AppKit does not always pair with it.)
+ override func viewDidChangeBackingProperties() {
+ super.viewDidChangeBackingProperties()
+ delegate?.pardesViewDidResize(self)
+ }
+}
+
+// ponytail: no NSTextInputClient, so dead keys and IME composition never reach
+// the core — keyDown reads `characters` and that is the whole story. Adopting
+// the protocol and routing through interpretKeyEvents is the upgrade when
+// someone needs to type Japanese.
diff --git a/src/macos/Sources/main.swift b/src/macos/Sources/main.swift
new file mode 100644
index 00000000..6cb2646e
--- /dev/null
+++ b/src/macos/Sources/main.swift
@@ -0,0 +1,16 @@
+import AppKit
+
+// Ghostty's ordering lesson: whatever must exist before AppKit boots goes above
+// app.run(), because after that call the runloop owns this thread forever.
+// pardes has nothing in that class — the core cannot come up until there is a
+// window to measure, so pardes_init waits for applicationDidFinishLaunching.
+
+let app = NSApplication.shared
+app.setActivationPolicy(.regular)
+
+// NSApplication does not own its delegate. This is a global, so it lives as
+// long as the process does; the same line inside a function would not.
+let delegate = AppDelegate()
+app.delegate = delegate
+
+app.run()
diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh
new file mode 100755
index 00000000..6734f8d3
--- /dev/null
+++ b/src/macos/build-app.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# Assemble pardes.app from libpardes.a and the Swift sources. Run it through
+# `zig build macos-app -Dplatform=macos`, or by hand with the install prefix as
+# $1 once `zig build -Dplatform=macos` has produced the library.
+#
+# There is no Xcode project on purpose. An .app is a directory with a plist and
+# a binary in it, swiftc ships with the Command Line Tools, and a hand-written
+# pbxproj would be a second build system to keep in step for no gain at this
+# stage. What Xcode buys — an xcframework of universal slices, codesigning,
+# notarization, a DMG — is distribution machinery; see docs/macos.md for the
+# upgrade path when that day comes.
+set -eu
+
+root=$(cd "$(dirname "$0")/../.." && pwd)
+out=${1:-"$root/zig-out"}
+app="$out/pardes.app"
+lib="$out/lib/libpardes.a"
+
+[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; }
+command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; }
+
+rm -rf "$app"
+mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist"
+
+# -import-objc-header rather than a module map: the header is consumed straight
+# from the source tree, so there is nothing to stage and nothing to keep in
+# sync. A module map is what an xcframework needs, and there isn't one.
+#
+# -lc++ because ghostty-vt pulls in simdutf and highway, which are C++. The Zig
+# side bundles compiler_rt/ubsan_rt into the archive (see build.zig), so the
+# C++ runtime is the only thing left for this link to supply.
+# -target is not optional. Without it swiftc uses the host triple, so
+# LC_BUILD_VERSION records whatever macOS built the thing and dyld refuses to
+# launch it on anything older — the Info.plist's LSMinimumSystemVersion is a
+# claim, not the enforcement. It is also what turns on the availability
+# diagnostics that catch a post-13 API before a user does.
+swiftc -O -target "$(uname -m)-apple-macos13.0" \
+ -import-objc-header "$root/src/macos/pardes.h" \
+ -o "$app/Contents/MacOS/pardes" \
+ "$root"/src/macos/Sources/*.swift \
+ "$lib" -lc++ \
+ -framework AppKit -framework CoreText -framework CoreGraphics
+
+echo "built $app"
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
new file mode 100644
index 00000000..e0d6fa32
--- /dev/null
+++ b/src/macos/pardes.h
@@ -0,0 +1,204 @@
+// libpardes — the C ABI the macOS app links against.
+//
+// Hand-written, and kept honest by a test: build.zig translate-C's this file
+// into the unit-test build, and src/macos.zig asserts every constant and
+// struct layout below against the Zig side (ghostty's trick — see docs/macos.md).
+// Change nothing here without running `zig build unit-test -Dplatform=macos`.
+//
+// Division of labour, which is the whole design in two lines:
+// Zig owns the core, the ptys, every effect, and the worker threads.
+// Swift owns NSApplication, the window, input translation, and drawing.
+//
+// There is exactly one core per process, so there are no handles: the state is
+// a file-scoped singleton, same as src/web.zig. Every function below must be
+// called from the main thread. The one exception is the `wakeup` callback,
+// which fires from a pty reader thread.
+
+#ifndef PARDES_H
+#define PARDES_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// ---------------------------------------------------------------- frame
+
+// One rendered cell. Mirrors pardes.Cell (src/pardes.zig) flattened for the
+// wire; the identical encoding is spelled a second time for the browser in
+// src/web.zig as WebCell.
+typedef struct {
+ // UTF-8 grapheme, `len` bytes. The core caps graphemes at seven bytes; the
+ // eighth is always zero so a debugger prints something sensible.
+ uint8_t text[8];
+ // 0x01000000 terminal default
+ // 0x02000000 | index 256-color palette entry
+ // 0x00RRGGBB direct color
+ uint32_t fg;
+ uint32_t bg;
+ // bit 0 bold, 1 dim, 2 italic, 3 blink, 4 reverse, 5 invisible,
+ // 6 strikethrough; bits 8.. hold PARDES_UL_*.
+ uint16_t attrs;
+ uint8_t len;
+ // bit 0: the core never painted this cell — draw it as the default cell
+ // (background only), which is what makes a partial redraw cheap.
+ uint8_t flags;
+} pardes_cell_s;
+
+#define PARDES_COLOR_DEFAULT 0x01000000u
+#define PARDES_COLOR_INDEXED 0x02000000u
+#define PARDES_COLOR_TAG_MASK 0xff000000u
+#define PARDES_COLOR_RGB_MASK 0x00ffffffu
+
+#define PARDES_ATTR_BOLD 0x0001u
+#define PARDES_ATTR_DIM 0x0002u
+#define PARDES_ATTR_ITALIC 0x0004u
+#define PARDES_ATTR_BLINK 0x0008u
+#define PARDES_ATTR_REVERSE 0x0010u
+#define PARDES_ATTR_INVISIBLE 0x0020u
+#define PARDES_ATTR_STRIKETHROUGH 0x0040u
+#define PARDES_ATTR_UL_SHIFT 8
+// Literals rather than (1u << n): Swift's macro importer is dependable on a
+// plain integer and less so on an expression, and nothing here can compile the
+// Swift side to find out. The ABI guard asserts each value against the encoder.
+
+#define PARDES_UL_OFF 0
+#define PARDES_UL_SINGLE 1
+#define PARDES_UL_DOUBLE 2
+#define PARDES_UL_CURLY 3
+#define PARDES_UL_DOTTED 4
+#define PARDES_UL_DASHED 5
+
+#define PARDES_CELL_DEFAULT 0x01u
+
+// ---------------------------------------------------------------- input
+
+// Modifier bitmask shared by key and mouse events.
+#define PARDES_MOD_CTRL 0x0001u
+#define PARDES_MOD_ALT 0x0002u
+#define PARDES_MOD_SHIFT 0x0004u
+
+// Keys that carry no text. The four editing keys are their ASCII controls;
+// everything else lives in a private-use plane so it can never collide with a
+// real codepoint arriving as text.
+#define PARDES_KEY_ENTER 0x0Du
+#define PARDES_KEY_ESCAPE 0x1Bu
+#define PARDES_KEY_TAB 0x09u
+#define PARDES_KEY_BACKSPACE 0x7Fu
+#define PARDES_KEY_UP 0xF0001u
+#define PARDES_KEY_DOWN 0xF0002u
+#define PARDES_KEY_LEFT 0xF0003u
+#define PARDES_KEY_RIGHT 0xF0004u
+#define PARDES_KEY_HOME 0xF0005u
+#define PARDES_KEY_END 0xF0006u
+#define PARDES_KEY_PAGE_UP 0xF0007u
+#define PARDES_KEY_PAGE_DOWN 0xF0008u
+#define PARDES_KEY_DELETE 0xF0009u
+
+// acme's three buttons carry the whole vocabulary: 1 selects, 2 executes,
+// 3 looks. Wheel buttons are ordinary buttons, not a separate axis.
+typedef enum {
+ PARDES_MOUSE_LEFT = 0,
+ PARDES_MOUSE_MIDDLE = 1,
+ PARDES_MOUSE_RIGHT = 2,
+ PARDES_MOUSE_WHEEL_UP = 3,
+ PARDES_MOUSE_WHEEL_DOWN = 4,
+ PARDES_MOUSE_WHEEL_LEFT = 5,
+ PARDES_MOUSE_WHEEL_RIGHT = 6,
+ PARDES_MOUSE_NONE = 7,
+} pardes_mouse_button_e;
+
+typedef enum {
+ PARDES_MOUSE_PRESS = 0,
+ PARDES_MOUSE_RELEASE = 1,
+ PARDES_MOUSE_MOTION = 2,
+ PARDES_MOUSE_DRAG = 3,
+} pardes_mouse_kind_e;
+
+// ---------------------------------------------------------------- runtime
+
+// What the host lends the core. Two callbacks, because everything else the
+// core wants done it already does itself: it owns the ptys, and it opens URLs
+// through /usr/bin/open. Copied by value during pardes_init, so the struct
+// need only outlive that call.
+typedef struct {
+ // Passed back to every callback below. Conventionally the AppDelegate.
+ void *userdata;
+
+ // "Your state moved; please pump me." Called from a pty reader thread, so
+ // the host must hop to the main thread before calling pardes_tick — see
+ // AppDelegate.wakeup, which does exactly one DispatchQueue.main.async.
+ void (*wakeup)(void *userdata);
+
+ // The yank register changed. `text` is borrowed for the duration of the
+ // call only. Main thread, inside pardes_tick.
+ void (*set_clipboard)(void *userdata, const char *text, size_t len);
+} pardes_runtime_s;
+
+// ---------------------------------------------------------------- lifecycle
+
+// Returns 0 on success, or a nonzero opaque error code. Pass the real window
+// grid, not a placeholder: init delivers it as the first resize EVENT (the
+// core defers each shell's greeting until it has seen one), and the first
+// forkpty takes its winsize straight off the core — boot at 80x24 and the
+// shell draws its first prompt to a width the window never had.
+int pardes_init(const pardes_runtime_s *runtime, uint16_t cols, uint16_t rows);
+void pardes_deinit(void);
+
+// Drain pty output into the core and perform the effects it queued. Call after
+// every input function and on every wakeup. Returns true if anything changed
+// and the host should mark its view dirty.
+bool pardes_tick(void);
+
+// The core asked to exit (the Exit builtin, or the last pane closing).
+bool pardes_should_quit(void);
+
+// A theme transition is mid-flight and wants ~60 Hz ticks until it settles.
+bool pardes_animating(void);
+
+// ---------------------------------------------------------------- events in
+
+// `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's
+// already-composed characters for printable keys and empty for functional
+// ones. `text` is borrowed for the call.
+void pardes_key(uint32_t cp, const char *text, size_t len, uint32_t mods);
+void pardes_paste(const char *text, size_t len);
+void pardes_mouse(pardes_mouse_button_e button, pardes_mouse_kind_e kind,
+ uint16_t col, uint16_t row, uint32_t mods);
+
+// Trackpad/precision wheel distance in rows, sign following the grid (positive
+// scrolls down). The core has no fractional scroll — it moves a row at a time —
+// so libpardes accumulates here and emits whole-row wheel presses, keeping the
+// remainder. Both other shells do this same accumulation host-side (stepScroll
+// in gui.zig, the drain loop in web/app.mjs); it lives in Zig here so the Swift
+// side stays a translator. A discrete wheel notch should go through
+// pardes_mouse instead.
+void pardes_scroll(float delta_rows, uint16_t col, uint16_t row);
+
+// `cell_w`/`cell_h` are one cell in physical pixels, which only the native PDF
+// placement path reads. Pass the backing-store size, not points.
+void pardes_resize(uint16_t cols, uint16_t rows, uint16_t cell_w,
+ uint16_t cell_h);
+
+// ---------------------------------------------------------------- frame out
+
+// Render one frame. Returns the cell count (cols * rows), or 0 on failure.
+// The buffer returned by pardes_frame_cells is valid until the next call.
+uint32_t pardes_frame(void);
+const pardes_cell_s *pardes_frame_cells(void);
+uint16_t pardes_frame_cols(void);
+uint16_t pardes_frame_rows(void);
+
+// -1 when the cursor is hidden. `bar` asks for a thin insert-mode caret.
+int32_t pardes_cursor_x(void);
+int32_t pardes_cursor_y(void);
+bool pardes_cursor_bar(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // PARDES_H
diff --git a/src/main.zig b/src/main.zig
index a3e7f862..343742e3 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -160,7 +160,10 @@ fn nativeMain(init: std.process.Init) !void {
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts),
.gui => try @import("gui/gui.zig").run(init, opts),
- .web => unreachable, // web enters through webMain, never here
+ // Both library shells are entered by their host through a flat C ABI,
+ // and never link this file at all: the browser through src/web.zig,
+ // the macOS app through src/macos.zig.
+ .web, .macos => unreachable,
}
}
diff --git a/src/pardes.zig b/src/pardes.zig
index 08536544..5d0ff9fb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -40,7 +40,7 @@ pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
-pub const Platform = enum { tty, gui, web };
+pub const Platform = enum { tty, gui, web, macos };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
/// Native PDF quality is a shell property, but the core owns MuPDF and the
@@ -64,7 +64,9 @@ pub const sdl_pdf_raster_policy: PdfRasterPolicy = .{
};
pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
.tty => kitty_pdf_raster_policy,
- .gui => sdl_pdf_raster_policy,
+ // Both pixel shells rasterize for a real display and can afford it; the
+ // wire-bandwidth argument that shapes the Kitty policy does not apply.
+ .gui, .macos => sdl_pdf_raster_policy,
.web => kitty_pdf_raster_policy,
};