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