From c3c8bbd8d8add99088c774c54bc1acf1e39ec895 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 8 Aug 2026 10:44:56 -0300 Subject: a native macOS backend: libpardes plus an AppKit shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass. --- build.zig | 59 ++- docs/ghostty-macos-notes.md | 198 ++++++++ docs/macos.md | 252 +++++++++++ src/look.zig | 2 +- src/macos.zig | 882 ++++++++++++++++++++++++++++++++++++ src/macos/Info.plist | 20 + src/macos/Sources/AppDelegate.swift | 179 ++++++++ src/macos/Sources/PardesView.swift | 478 +++++++++++++++++++ src/macos/Sources/main.swift | 16 + src/macos/build-app.sh | 45 ++ src/macos/pardes.h | 204 +++++++++ src/main.zig | 5 +- src/pardes.zig | 6 +- 13 files changed, 2339 insertions(+), 7 deletions(-) create mode 100644 docs/ghostty-macos-notes.md create mode 100644 docs/macos.md create mode 100644 src/macos.zig create mode 100644 src/macos/Info.plist create mode 100644 src/macos/Sources/AppDelegate.swift create mode 100644 src/macos/Sources/PardesView.swift create mode 100644 src/macos/Sources/main.swift create mode 100755 src/macos/build-app.sh create mode 100644 src/macos/pardes.h 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=").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 = ""`, 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 @@ + + + + + CFBundleName + pardes + CFBundleIdentifier + cafe.0x4200.pardes + CFBundleExecutable + pardes + CFBundlePackageType + APPL + NSHighResolutionCapable + + LSMinimumSystemVersion + 13.0 + NSPrincipalClass + NSApplication + + 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.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..= 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 +#include +#include + +#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, }; -- cgit v1.3