diff options
34 files changed, 5241 insertions, 369 deletions
@@ -6,3 +6,4 @@ assets/MapleMono-NF-Regular.ttf # a failing snapshot writes <stem>.actual beside its golden; it is the failure # report, not a source of truth, and eleven had been committed by accident test/snapshots/*.actual +test/macos-snapshots/*.actual @@ -1,10 +1,19 @@ const std = @import("std"); +const builtin = @import("builtin"); const mupdf_build = @import("mupdf.zig"); const snap_build = @import("build/snap.zig"); const grammar_manifest = @import("src/grammar_manifest.zig"); pub const Platform = enum { tty, gui, web, macos }; +/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things +/// have to agree about it or the bundle is a lie: the target this build gives +/// the static library, the `-target` build-app.sh passes swiftc (which is what +/// writes LC_BUILD_VERSION, the thing dyld actually enforces), and the plist's +/// LSMinimumSystemVersion. The script gets it as an argument and sets the last +/// two from it, so there is one string and no drift. +pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 }; + /// 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 /// `-Dversion-string` (its build.zig otherwise shells out to `git describe`, @@ -13,19 +22,41 @@ const zls_version = "0.16.1-dev+3e0d0820"; pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; pub fn build(b: *std.Build) void { + const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty; // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu // with the glibc version pinned low, so a binary built on a rolling- // release host runs on SteamOS — a native build references the host's // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" // on the deck. Override with -Dtarget= as usual. - const target = b.standardTargetOptions(.{ .default_target = .{ - .cpu_arch = .x86_64, - .os_tag = .linux, - .abi = .gnu, - .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, + // + // -Dplatform=macos cannot take that default, and the failure is not + // subtle: swiftc links this archive, so a Steam Deck build hands ld64 ELF + // objects inside a GNU archive and the app link dies with "archive member + // '/SYM64/' not a mach-o file". On a Mac it therefore targets the host + // arch at macos_min_version — the same triple build-app.sh gives swiftc, + // so neither half of the app can disagree with the other about how old a + // macOS it supports. Naming the arch rather than leaving it null is + // ghostty's workaround (Config.genericMacOSTarget): a spelled arch + // resolves the CPU model to generic, where a bare native query would bake + // in apple_m2 and everything its LLVM backend has opinions about. + // + // Anywhere else it stays plain native, which is the whole point of the + // Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a + // binary that machine can actually execute. + const target = b.standardTargetOptions(.{ .default_target = switch (platform) { + .macos => if (builtin.os.tag.isDarwin()) .{ + .cpu_arch = builtin.target.cpu.arch, + .os_tag = .macos, + .os_version_min = .{ .semver = macos_min_version }, + } else .{}, + .tty, .gui, .web => .{ + .cpu_arch = .x86_64, + .os_tag = .linux, + .abi = .gnu, + .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, 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; @@ -655,14 +686,70 @@ pub fn build(b: *std.Build) void { // 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); + // ...and neither does bundling stop at compiler_rt. addLibrary emits + // ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and + // ghostty-vt's simdutf/highway stay in archives of their own that zig + // would hand a linker it drives itself. swiftc drives this one, is + // given one file, and fails with a page of undefined C++ symbols. So + // everything reachable is folded into a single archive first — this is + // ghostty's CombineArchivesStep, minus the non-Darwin half. + // libtool and ranlib are Apple's, so this needs a Darwin host as well + // as a Darwin target; a cross-build installs the plain archive, which + // is all the Linux dev loop ever links (nothing). + if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) + b.getInstallStep().dependOn(&b.addInstallLibFile(fatArchive(b, lib), "libpardes.a").step) + else + 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, "")); + // The deployment target travels as an argument rather than being + // written twice: see macos_min_version. + app.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); + // ...and so does the optimize mode, so swiftc compiles the shell the + // same way zig compiled the core. Hardcoding -O there meant the default + // `zig build macos-app` shipped an optimized shell around a Debug core, + // which costs 5x a frame and looks like a slow backend rather than a + // Debug build. See the note in build-app.sh. + app.addArg(@tagName(optimize)); 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); + // The library the script links has to BE a Mach-O one. Cross-building + // the ABI for another host is supported and tested (see the Linux dev + // loop in docs/macos.md); assembling a bundle out of it is not. + const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)"); + if (target.result.os.tag.isDarwin()) + app_step.dependOn(&app.step) + else + app_step.dependOn(&b.addFail("macos-app needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); + + // The offscreen AppKit suite. A second link rather than a flag on the + // app: test scaffolding does not ship in the product, and main.swift's + // top-level code is already an entry point (see src/macos/build-e2e.sh). + // Same two arguments as the app, because it must be the same link. + const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"}); + e2e.addArg(b.getInstallPath(.prefix, "")); + e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); + e2e.has_side_effects = true; // writes a binary outside the cache + e2e.step.dependOn(b.getInstallStep()); + const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")}); + // Relative, and pinned to the build root: the scripts and their goldens + // are source, not an install artifact, and the harness chdirs into a + // hermetic /tmp world per script — so it resolves both up front and + // must start somewhere it knows. + run_e2e.setCwd(b.path(".")); + run_e2e.addArg("test/macos-snapshots"); + // `-- --update` reaches the harness this way, exactly as the tty suite + // takes it: regenerating goldens is the same binary with one more flag. + if (b.args) |args| run_e2e.addArgs(args); + run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens + run_e2e.step.dependOn(&e2e.step); + const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)"); + if (target.result.os.tag.isDarwin()) + e2e_step.dependOn(&run_e2e.step) + else + e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); // Same step name the other native platforms use, because in this // configuration theirs is not declared: the ABI guard and the core's @@ -923,6 +1010,36 @@ fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { b.getInstallStep().dependOn(fail); } +/// Fold `lib` and every static archive it transitively links into one file, +/// because swiftc is handed exactly one. getCompileDependencies walks the +/// module graph, so this stays correct as dependencies come and go — nothing +/// here names MuPDF or tree-sitter, and adding a third C library needs no edit. +fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { + const run = std.Build.Step.Run.create(b, "libtool libpardes.a"); + run.addArgs(&.{ "libtool", "-static", "-o" }); + const output = run.addOutputFileArg("libpardes.a"); + for (lib.getCompileDependencies(false), 0..) |dep, i| { + if (dep.kind != .lib) continue; + run.addFileArg(reindexed(b, dep.getEmittedBin(), i)); + } + return output; +} + +/// Rewrite one archive's index with Apple's ranlib, on the way past. Two of +/// Xcode's tools disagree with zig's archive layout and neither says so +/// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o' +/// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input +/// came back as a 13 MB output with half the objects missing, which links +/// almost far enough to look like a source problem. ranlib rewrites both +/// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the +/// copy is because ranlib works in place and a build-cache input is not ours. +fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath { + const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index})); + run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" }); + run.addFileArg(archive); + return run.addOutputFileArg(b.fmt("{d}.a", .{index})); +} + // glslc <src> -fshader-stage=<stage> -o <out> -> .spv, returned as a LazyPath // for @embedFile. Only used by the SDL3 GPU shell (-Dplatform=gui). fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u8) std.Build.LazyPath { diff --git a/docs/macos.md b/docs/macos.md index e430f677..b1666134 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -85,14 +85,20 @@ 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 +`pardes_scroll` carries fractional row and column deltas from a trackpad; the +Zig side accumulates each axis separately and synthesizes whole +`wheel_up`/`wheel_down`/`wheel_left`/`wheel_right` 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. +smoothing and goes through `pardes_mouse`. `pardes_rotate` is the same shape for +a two-finger twist, spent as `n`/`N` — see the trackpad section. +`pardes_command` runs one builtin command line through the core's own `command` +event, which is the channel a nested pardes speaks; here it is what a menu item +is made of and what opens a path from argv or the Dock (`Look <path>`). +`pardes_take_haptic` reports the Look or Exec the core just performed and clears +it. `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` @@ -126,6 +132,209 @@ 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. +## The trackpad is the third button + +acme wants three mouse buttons — 1 selects, 2 executes, 3 looks — and the +machine this runs on has a glass rectangle. So the rectangle is taught to speak +the vocabulary, and the mapping is the one macOS itself already suggests: + +| gesture | button | verb | +| --- | --- | --- | +| one finger | 1 | select | +| two fingers | 3 | Look | +| three fingers | 2 | Exec | +| a deep press | 2 | Exec | +| two fingers twisted | — | `n` / `N` | + +**The finger count decides, not the button stream.** This is the part that only +real hardware could teach, and it is worth spelling out because the obvious +implementation is wrong. macOS's secondary click is "click or tap with **two or +more** fingers", so with that setting on — the default — a *three*-finger click +is delivered as `rightMouseDown` exactly like a two-finger one. A view that +trusts the stream cannot tell them apart and quietly does Look for both. The +trace that caught it, from a real trackpad: + +``` +pardes: rightMouseDown: resting=2 +pardes: rightMouseDown: resting=3 +``` + +So all three button streams funnel into one `beginClick`, which resolves the +button from the fingers first and falls back to the stream only when there are +no fingers to count — which is exactly the real-mouse case, where right is Look +and the middle button is Exec. + +The count comes from `event.touches(matching: .touching, in: nil)`, with `nil` +rather than the view because that argument filters on touch/view association and +an association that fails does not raise, it returns zero fingers — a +two-finger Look silently degrading into a select. Belt and braces: the view also +keeps a running `restingFingers` from the four `touchesXxx` callbacks, because +the touch set hanging off a *mouse* event is an accident of how the click was +produced and can come back empty. The mouse event's own set wins when it has +anything in it. + +Whatever it resolves to is then **latched** for the drag and the release: the +core tracks a drag keyed by button, and answering a press of 3 with a release of +1 strands it holding a sweep nothing will ever end. + +A deep press arrives as `pressureChange` reaching stage 2, and only the +transition counts — AppKit repeats stage 2 for as long as the finger stays down. +By then a press has already gone out, so it is *released* before the middle one +is sent. That ordering is not tidiness: a middle press arriving while the core +holds a left select-drag is acme's 1-2 chord, which is **Cut**. The release +costs a cursor move at the click point, which is what clicking there would have +done anyway. + +Which press gets upgraded is deliberately not restricted to the left one, and +that too came from the trace: on a Force Touch trackpad the deep press usually +rides a click that already went out on the *right* stream, so gating on a +latched left button meant the conversion never fired at all — the log showed +`pressure: stage=2 latched=nil` and nothing else. Any in-flight click upgrades; +already-Exec is the only case with nothing to do. The view also needs +`NSPressureConfiguration(pressureBehavior: .primaryDeepClick)` or stage 2 is the +system's business and never arrives — and the user needs "Force Click and +haptic feedback" on in System Settings, which nothing in this process can read. + +Twisting two fingers is a dial, and a dial over a list of search hits is `n`. +`pardes_rotate` takes raw degrees and libpardes quantizes them, one search step +per 20°, keeping the remainder — the same accumulate-and-spend shape as +`pardes_scroll`, in Zig for the same reason: it is then unit-tested on a machine +with no trackpad. AppKit reports counterclockwise as positive and the forward +step is clockwise, so the sign inverts here and nowhere else. The banked +remainder is deliberate hysteresis; a gesture beginning passes 0 to clear it, so +the first degree of a new twist cannot inherit a nearly-complete notch from the +last one. Measured against a real twist: 95 events, mean 3.3° each, 20° notches, +twelve search steps — the granularity is right, and the reason a twist can look +like it does nothing is that `n` has nowhere to step until a search is armed. + +## When a gesture looks like it did nothing + +All three verbs are cheap to mistake for broken, because acme's verbs are about +the *word under the pointer* and most words resolve to nothing: + +- **Look** (two fingers) on a filename opens it; on a word that names no file + and matches nothing else on screen, it searches, finds where it already is, + and the screen does not move. The pulse still fires — the gesture worked. +- **Exec** (three fingers) on a builtin name runs it. On ordinary prose it types + that word at a shell, which needs a terminal pane to type into. +- **`n`/`N`** (twist) steps a results buffer. With no `/pattern`, Grep or Find + behind it there is nothing to step. + +`PARDES_LOG=1` prints every decoded gesture to stderr — the fingers counted, the +stream it came in on, the button it resolved to, the pressure stage, the +rotation degrees. It exists because which events a trackpad produces is decided +by hardware plus four System Settings switches this process cannot read, and +because guessing at that from a screenshot cost an afternoon. + +## Haptics + +Every Exec and every Look taps the trackpad. The core arms a one-slot pulse in +the ONE dispatcher — `lookAt` and `execute`, the two functions a middle click, a +right click, Enter, Tab, a tag chord, `n`/`N` stepping and the `Look`/`Exec` +builtins all funnel into — and the host takes it once per pump with +`pardes_take_haptic`. Exec gets `.generic`, the definite tap of something done; +Look gets `.alignment`, the lighter detent AppKit uses when a dragged guide +snaps. A pulse, not a queue: five Execs inside one keystroke are one thing the +hand did. + +Three details are load-bearing. `execute` arms only at `exec_depth == 0`, +because `Exec ls` re-enters as `ls` and one Tab is one gesture however many +words it unwraps to. `init` and `initFromDump` take the pulse and drop it, so a +config file that opens a file with `Look` does not buzz at boot. And the field +is `HapticSlot`, `void` on every platform but this one, the way `PdfSlot` is +`void` without MuPDF — no other shell reads it, so no other shell carries it. + +There is no capability check. `NSHapticFeedbackManager` is a silent no-op +without a Force Touch trackpad and when the user has feedback switched off, so a +check here would only be a second place to be wrong — and it would be wrong the +moment an external trackpad is plugged in mid-session. + +## libproc, twice + +Two features on this backend want to know something about a process that is not +us, and on Linux both answers live in `/proc`. Darwin's equivalent is libproc, +and it answers both. + +**A pane's cwd** (`look.shellCwd`) is `readlink("/proc/<pid>/cwd")` there and +`proc_pidinfo(PROC_PIDVNODEPATHINFO)` here. The tag shows it and a relative +`Look` resolves against it, so it has to follow the shell rather than stay +where the pane was spawned. + +*When* it is read differs from the other two shells, and deliberately. The tty +and SDL hosts poll every pane every frame; here the drain has just finished +saying exactly which shells produced bytes, and nothing else can have moved +one — a `cd` is a command, and a shell that ran a command writes at least its +next prompt. So `refreshCwds` reads only for panes flagged by that tick's +output and an idle session costs no syscalls at all. `test/macos-snapshots/ +cwd.snap` holds the gating to it: the tag must be right after a `cd` and must +survive a tick with nothing in it. + +**A pardes inside a pardes** (`src/nested.zig`) walks the ancestor chain +looking for our own executable, and hands the file over rather than stacking a +second full-screen UI inside a pane. `readlink("/proc/<pid>/exe")` becomes +`proc_pidpath`, and the `PPid:` line of `/proc/<pid>/status` becomes +`proc_bsdinfo.pbi_ppid`. That struct is hand-written, which is a thing to get +silently wrong: a field ordering that puts something else where `ppid` should +be still returns a plausible number, so a unit test compares `parentOf(getpid())` +against `getppid()`. + +The socket half needed real portability work rather than a second spelling. +Darwin has no `SOCK_CLOEXEC` and no `accept4`, so the flag is set with an +`fcntl` after the fact — a race only against a fork on another thread, and both +callers are past that. `sun_path` is 104 bytes here against 108 there, so no +buffer in the file spells a number any more; they are all sized from the field +itself, and an address that does not fit is refused rather than truncated into +a path pointing somewhere else. + +Identity gained a third sibling. `bin/pardes` and +`pardes.app/Contents/MacOS/pardes` are one build installed twice and share no +directory at all, so the comparison is made at the *install prefix* — the +directory holding the `.app`, or the parent of a `bin` — and the bundle's name +can never carry the `-os-arch` tail the installed binary does, because +`CFBundleExecutable` is a fixed string. `zig build macos-app` then `pardes +src/foo.zig` inside the app's own shell opens a pane in the app. + +## Fonts and zoom + +The face is the shell's business and the size is the window's, so the two are +reached differently on purpose. + +`Font <name>` and the `FontSel` picker are ordinary core builtins, enabled by +`pardes.font_picker` — the frontends that draw their own text, which is now the +SDL shell and this one. `src/fonts.zig` moved out of `gui/` for that reason. It +walks the platform's font directories and reads four small sfnt tables per file +to decide whether every glyph has the same advance; no fontconfig and no +CoreText, so both shells agree about which faces exist and disagree only about +how to rasterize one. + +macOS needed two things from that walk. Its directories are +`/System/Library/Fonts`, that plus `Supplemental`, `/Library/Fonts` and +`~/Library/Fonts`; and a third of what is in them — Menlo and Courier +included — is a `.ttc` collection rather than a plain face. A collection is a +`ttcf` header in front of several sfnt directories, and the table offsets +inside one are absolute from the start of the file, so reading face 0 is a +matter of finding where its directory begins and changing nothing else. + +The answer crosses the ABI as a PATH, not a family name: the core already found +the file, and asking CoreText to resolve a name would be a second lookup that +can disagree. `pardes_font_take` hands it over once, the same take-and-clear +shape as the haptic, and the view loads it with +`CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a +collection, derives bold and italic from it, and re-measures. A file it cannot +wear leaves the screen exactly as it was — a terminal that cannot draw has no +way back out of itself. + +Zoom does not touch the core at all. Cmd+, Cmd- and Cmd+0 change the point size, +`Metrics` is rebuilt, and the new cell is reported through the same resize path +a window drag uses; the core reflows to a different number of columns and knows +nothing about points. Cmd+= rather than Cmd++ because AppKit matches the +character and `=` is what is under the finger. + +Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two +different kinds of assertion because a snapshot is the core's cell buffer and +the core has no font: `font Menlo-Regular` asks the view what it is actually +wearing, and the snapshots catch the grid moving when the cell changes size. + ## Threading One core, touched only from the main thread, plus one pty reader task per pane. @@ -149,32 +358,64 @@ 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. +beside it. + +`-Dplatform=macos` is the one platform that overrides the repo's default +target. Everything else defaults to the Steam Deck (x86_64 linux-gnu, glibc +pinned low), and that default is not survivable here: swiftc links this archive, +so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app +link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the +default becomes the host arch at `macos_min_version`, which is the same triple +`build-app.sh` gives swiftc, so the two halves of the app cannot disagree about +how old a macOS they support. On any other host it stays plain native, which is +what keeps the Linux dev loop below runnable. + +That archive is also *fat*. `b.addLibrary` emits only this module's own objects; +MuPDF, tree-sitter, zstbi, ZLS and ghostty-vt's simdutf/highway stay in archives +of their own that zig would normally hand to a linker it drives itself. swiftc +drives this one and is given a single file, so `fatArchive` in `build.zig` walks +`getCompileDependencies` and folds every static archive into one with Apple's +`libtool`. This is ghostty's `CombineArchivesStep` minus the non-Darwin half, +and it inherits ghostty's two hard-won details: each input is copied and run +through `ranlib` first, because ld64 otherwise refuses zig's layout outright +(`64-bit mach-o member 'compiler_rt.o' not 8-byte aligned`) and libtool silently +*drops* members from it — a 15 MB input came back as 13 MB with half the objects +missing, which links almost far enough to look like a source problem. ```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: +runs `src/macos/build-app.sh`, which is the whole second half: it copies +`Contents/Info.plist` and stamps `LSMinimumSystemVersion` from the version +build.zig passed it, compiles `src/macos/icon.swift` and runs it to emit +`Contents/Resources/pardes.icns`, and links the app: ```sh -swiftc -O -import-objc-header src/macos/pardes.h \ +swiftc -O -target "$(uname -m)-apple-macos$minver" \ + -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. +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. `-target` is not optional: without it swiftc uses the +host triple, `LC_BUILD_VERSION` records whatever macOS built the thing, and dyld +refuses to launch it on anything older — the plist's `LSMinimumSystemVersion` is +a claim, not the enforcement. The deployment version is spelled once, as +`macos_min_version` in `build.zig`, and reaches the `-target`, the plist and the +library's own target from there. + +The icon is generated rather than committed: no binary blob in the tree, and its +palette stays in step with the one `PardesView` draws with, because both read +the same constants. No Xcode project, no xcframework, no `lipo`, no codesigning — ghostty has all four (`macos/Ghostty.xcodeproj`, `src/build/GhosttyXCFramework.zig`, the entitlements @@ -186,29 +427,32 @@ 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 +## Testing + +Three layers, and each one exists because the layer above it cannot reach where +it goes. -The Zig half of this backend is plain POSIX. `forkpty`, `read`, `write`, -`ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty backend by a string -constant. So `-Dplatform=macos` compiles on a Linux host, and its tests run -there: +**The Linux dev loop.** The Zig half of this backend is plain POSIX. `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. +That covers the ABI's Zig side, the effect drain, and the two quantizers the +trackpad depends on — `takeScrollTicks` and `takeRotationNotches` are pure +functions precisely so that "how many search steps is a 180° twist" is answerable +on a machine with no trackpad in it. 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. +tags, the attribute bits, the key codepoints, the mouse, modifier and haptic +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 @@ -216,7 +460,76 @@ 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. +reading both files would have caught it. It earned it a second time when the +same function grew a horizontal axis. + +**The offscreen AppKit suite.** Everything above stops at the ABI. This one +drives the real `PardesView` in a real (borderless, offscreen, activation- +prohibited) `NSWindow`, over a real core with real ptys: + +```sh +zig build macos-e2e -Dplatform=macos # run it +zig build macos-e2e -Dplatform=macos -- --update # regenerate the goldens +zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap +``` + +`test/macos_e2e.swift` links the same Swift sources the app does, minus +`main.swift`, into a second binary — test scaffolding does not ship inside the +product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's +vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`) plus what only +exists here: `fingers <n>`, `force`, `rotate <degrees>`, `scroll`, `haptic +<none|exec|look>` and `draw`. Output is byte-identical in shape to +`test/snapshot.zig`'s, so a grid captured through CoreText and one captured +through a pty can be read side by side. + +This is the layer that can assert the trackpad features, and the reason it can +is that `NSTouch`, pressure stages and rotation have **no public +constructors** — a test can never synthesize the events. So the view is built +with the decision one call below the event: every override decodes and then +calls `press`/`release`/`click`/`rotate`/`typeKey`, and `Trackpad.button(fingers:)` +is pure policy with no `NSEvent` in it. The scripts drive those, which is +everything except the two lines that read the properties off the event. `haptic` +reads `pardes_take_haptic` back, which is how a pulse is asserted on a machine +that cannot feel one; `draw` renders the view with `cacheDisplay` and fails if +every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap` +reads the core's cell buffer and would be perfectly happy with a `draw` that +returned on its first line. + +Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the +config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and +`TMPDIR` inside the per-script world so that `New`'s document has a reproducible +directory — its six mkstemp characters are masked on capture. + +**The app itself.** `zig build macos-app -Dplatform=macos && open zig-out/pardes.app`. +Some things only a hand can test: which System Settings checkbox is on, what a +deep press feels like, whether the haptic lands with the click or after it. + +## Performance + +Measured on an M2, one window at 190x56 (1710x984 points), timing `draw(_:)` +and its phases over 60 frames of a shell pouring out four thousand lines. + +| | Debug core | ReleaseFast core | +|---|---|---| +| `pardes_frame` | 4119 us | 413 us | +| background pass | 160 us | 187 us | +| glyph pass | 226 us | 264 us | +| **whole `draw`** | **4516 us** | **879 us** | + +The finding is the first row, and it is not about drawing at all. `swiftc` was +hardcoded to `-O` in `build-app.sh` while the Zig core followed `-Doptimize`, +so the ordinary `zig build macos-app` shipped an optimized shell wrapped around +a Debug core — and that reads as "the mac backend is slow" rather than "you +built Debug". The mode now travels as the script's third argument, both halves +are compiled the same way, and a Debug bundle says so on the way out. Build one +you intend to *use* with `-Doptimize=ReleaseFast`. + +What is left is honest: 0.88 ms against a 16 ms frame, and the Swift half is +0.45 ms of it. Nothing here is a CoreText problem yet. The two things that +would be worth doing before reaching for Metal, if a bigger window ever makes +this matter, are both in `pardes_frame` rather than in the view — it re-renders +every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect` +for exactly that reason. ## Not implemented @@ -233,20 +546,39 @@ reading both files would have caught it. `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 +- **IME and marked text.** Only finished characters reach `pardes_key`, so a + dead key composes nothing and Option is Alt rather than a compose modifier. + Real composition means implementing `NSTextInputClient` *and* giving the core + a way to render an underlined preedit run, which no backend has yet — the + second half is why this is not just an AppKit protocol away. +- **Tabs and splits at the window level.** One window, one grid; window tabbing + is switched off rather than left to produce an empty second window. 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. +- **A glyph atlas.** Drawing is CoreText per row: runs of cells sharing a face + and a colour go out as one `CTFontDrawGlyphs`, ASCII glyph ids are resolved + once per face at init and everything else is cached on first sight. That is + enough for a grid this size, and it is still a cmap-and-rasterizer path where + the SDL shell has a 2048² atlas. It has now been profiled rather than + guessed at (see Performance): the glyph pass is 264 us of an 879 us frame, + which is not where the time is, so the escalation is still not warranted. + When it is, it is ghostty's: a `CAMetalLayer` installed into the view and + driven from Zig, with the ABI growing one `platform` pointer field. +- **A Tahoe icon asset.** `src/macos/icon.swift` emits a full-colour `.icns`, + every one of the ten sizes, and that is the correct and only format at a 13.0 + deployment target. macOS 26's Dock defaults to the `ClearAutomatic` icon + style, which desaturates any icon that does not ship the new appearance + variants, so ours renders there in grey while apps built with Icon Composer + keep their colour. Matching them means an `Assets.car` produced by an Xcode 26 + tool, which is the first thing in this backend that would actually require + Xcode — hence not done. The file itself is verifiably correct: `iconutil -c + iconset` round-trips all ten, and the tag bar is `#3465A4` at every size. +- **Distribution.** No codesigning, no notarization, no universal binary, no + bundled fonts, no localization. The app has an icon, a plist that says what it + opens, and a deployment target it actually enforces; everything past that is + Gatekeeper's business and starts with `lipo`. diff --git a/src/builtins.zig b/src/builtins.zig index 3ccbdeed..f747765d 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -35,7 +35,7 @@ const config = @import("config.zig"); /// GUI-only file behind a comptime branch, the way look.zig imports the web's /// source archive: the tty and web builds evaluate the other arm and compile /// none of it. -const fonts = if (pardes.platform == .gui) @import("gui/fonts.zig") else struct {}; +const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {}; /// What a builtin gets to act on. One bundle rather than five parameters /// because most builtins want two of them and zig rejects the unused rest. @@ -343,7 +343,7 @@ pub const Crt = struct { /// its next pass and re-rasters. Exactly the shape Restore already has. pub const Font = struct { pub const takes_arg = true; - pub const enabled = pardes.platform == .gui; + pub const enabled = pardes.font_picker; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } @@ -369,7 +369,7 @@ pub const Font = struct { /// them would mean the list wearing one on the way past. See fonts.monospaced. pub const FontSel = struct { pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .executes = true }; - pub const enabled = pardes.platform == .gui; + pub const enabled = pardes.font_picker; pub fn run(c: Ctx) void { if (comptime enabled) apply(c) else unreachable; } diff --git a/src/config.zig b/src/config.zig index 67314fd9..38af0a67 100644 --- a/src/config.zig +++ b/src/config.zig @@ -163,7 +163,7 @@ pub const leader_path = paths: { // web they are not builtins at all (see builtins.zig) and the literal's // type has no field to write. `Font` takes a NAME, so it has no path, for // the same reason `Theme` has none. - if (pardes.platform == .gui) { + if (pardes.font_picker) { table.set(.FontSel, "tf"); table.set(.Font, null); } diff --git a/src/gui/fonts.zig b/src/fonts.zig index 54210136..28789e3d 100644 --- a/src/gui/fonts.zig +++ b/src/fonts.zig @@ -1,29 +1,43 @@ //! The fonts installed on the machine: the list the picker shows, the path a //! `Font <name>` resolves to, and the one word the two sides of that say to //! each other. builtins.zig reads this file to build the rows and to resolve a -//! name; gui.zig reads it to learn which file to load. It is the whole seam, -//! because the core has no font and the shell has no builtin dispatch. +//! name; gui.zig and macos.zig read it to learn which file to load. It is the +//! whole seam, because the core has no font and the shell has no builtin +//! dispatch. //! -//! It lives under gui/ rather than at src/ — where the core lies flat — -//! because it only exists in a GUI build: builtins.zig imports it behind -//! `platform == .gui`, so the tty binary compiles not one line of this and -//! never opens a font directory, and the browser (which has no font -//! directories to open) is out for a better reason than taste. +//! It lives at src/ rather than under gui/ because two shells now draw their +//! own text: builtins.zig imports it behind `platform == .gui or .macos`, so +//! the tty binary compiles not one line of this and never opens a font +//! directory, and the browser (which has no font directories to open) is out +//! for a better reason than taste. //! -//! No fontconfig. Enumerating fonts on a Unix box is a walk over four -//! well-known directories, and the one thing the picker must know about each -//! face — whether every glyph has the same advance — is four small sfnt reads. -//! That avoids initializing a FreeType face for every file in the directory. +//! No fontconfig and no CoreText. Enumerating fonts is a walk over a handful +//! of well-known directories, and the one thing the picker must know about +//! each face — whether every glyph has the same advance — is four small sfnt +//! reads. That avoids initializing a FreeType face for every file in the +//! directory, and it keeps the answer identical on both platforms: the shells +//! disagree about how to RASTERIZE a file, never about which files there are. const std = @import("std"); +const builtin = @import("builtin"); const libc = std.c; -/// Where a unix box keeps fonts. The last two are relative to $HOME (a machine -/// with no $HOME simply has neither). ponytail: this is the freedesktop list -/// minus /usr/share/X11/fonts, whose legacy bitmap formats are outside this -/// TTF/OTF picker; XDG_DATA_DIRS is the general answer if another font root -/// becomes common. -const system_dirs = [_][]const u8{ "/usr/share/fonts", "/usr/local/share/fonts" }; -const home_dirs = [_][]const u8{ ".local/share/fonts", ".fonts" }; +/// Where each platform keeps fonts. The `home` list is relative to $HOME (a +/// machine with no $HOME simply has neither). ponytail: the unix set is the +/// freedesktop list minus /usr/share/X11/fonts, whose legacy bitmap formats +/// are outside this TTF/OTF picker; XDG_DATA_DIRS is the general answer if +/// another font root becomes common. +/// +/// macOS keeps a third of its faces — Menlo and Courier among them — inside +/// `Supplemental`, which is an ordinary directory the walk would reach anyway; +/// it is named because the depth cap is the only thing that would stop it. +const system_dirs = switch (builtin.os.tag) { + .macos => [_][]const u8{ "/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts" }, + else => [_][]const u8{ "/usr/share/fonts", "/usr/local/share/fonts" }, +}; +const home_dirs = switch (builtin.os.tag) { + .macos => [_][]const u8{"Library/Fonts"}, + else => [_][]const u8{ ".local/share/fonts", ".fonts" }, +}; /// The same three safety rails look.find has, for the same reason: this walk /// runs INSIDE the keystroke that asked for it, so it must end whatever it is @@ -113,7 +127,13 @@ fn scan(arena: std.mem.Allocator, wanted: ?[]const []const u8) []const Font { continue; } const ext = std.fs.path.extension(e.basename); - if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and !std.ascii.eqlIgnoreCase(ext, ".otf")) continue; + // .ttc is a collection: several cuts of one family in a single + // file, which is how macOS ships Menlo, Courier and a third of + // everything else. The probe below reads face 0 out of one, and + // the shell loading it takes the same face — see tableOffset. + if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and + !std.ascii.eqlIgnoreCase(ext, ".otf") and + !std.ascii.eqlIgnoreCase(ext, ".ttc")) continue; const name = e.basename[0 .. e.basename.len - ext.len]; if (wanted) |names| { var matches = false; @@ -239,21 +259,59 @@ fn monospaced(path_z: [*:0]const u8) bool { /// Where `tag`'s table starts, read out of an sfnt table directory. Called /// twice per font, which is the only reason it is not inline up there. fn tableOffset(head: []const u8, tag: *const [4]u8) ?u32 { - if (head.len < 12) return null; + const dir = head[sfntBase(head) orelse return null ..]; + if (dir.len < 12) return null; // 0x00010000 TrueType outlines, "OTTO" CFF ones, "true" the old Apple - // spelling. Anything else — a .ttc collection, a WOFF, a lie about its - // extension — is not an sfnt face this picker can inspect. - const ver = std.mem.readInt(u32, head[0..4], .big); + // spelling. Anything else — a WOFF, a lie about its extension — is not an + // sfnt face this picker can inspect. + const ver = std.mem.readInt(u32, dir[0..4], .big); if (ver != 0x00010000 and ver != 0x4F54544F and ver != 0x74727565) return null; - const num = std.mem.readInt(u16, head[4..6], .big); + const num = std.mem.readInt(u16, dir[4..6], .big); var i: usize = 0; - while (i < num and 12 + (i + 1) * 16 <= head.len) : (i += 1) { - const rec = head[12 + i * 16 ..][0..16]; + while (i < num and 12 + (i + 1) * 16 <= dir.len) : (i += 1) { + const rec = dir[12 + i * 16 ..][0..16]; + // Table offsets in a collection are from the start of the FILE, not + // from the directory that named them, so this needs no adjusting. if (std.mem.eql(u8, rec[0..4], tag)) return std.mem.readInt(u32, rec[8..12], .big); } return null; } +/// Where the sfnt table directory begins. Zero for an ordinary font file; for +/// a `ttcf` collection, the offset of face 0 — the cut the file is named +/// after, and the one a shell asked for this path will load. A collection +/// whose first face lies past what was read has no answer here rather than a +/// guessed one. +fn sfntBase(head: []const u8) ?usize { + if (head.len < 12) return null; + if (!std.mem.eql(u8, head[0..4], "ttcf")) return 0; + if (head.len < 16) return null; + if (std.mem.readInt(u32, head[8..12], .big) == 0) return null; // numFonts + const base = std.mem.readInt(u32, head[12..16], .big); + return if (base + 12 <= head.len) base else null; +} + +/// A scratch font path only this process writes. +/// +/// Not a fixed name: `zig build unit-test` compiles this module into two test +/// binaries and runs them CONCURRENTLY, so a shared path in /tmp is one test +/// truncating the file another is halfway through reading. That surfaced as +/// `monospaced` flatly disagreeing with the bytes it had just been handed, +/// about one run in three, which reads like a bug in the probe and is not one. +fn scratchFont(buf: *[64:0]u8, ext: []const u8) [:0]const u8 { + return std.fmt.bufPrintSentinel(buf, "/tmp/pardes-fonts-test-{d}{s}", .{ + @as(u32, @intCast(libc.getpid())), ext, + }, 0) catch unreachable; +} + +/// Write `bytes` where `monospaced` can read them back. +fn writeScratch(path: [:0]const u8, bytes: []const u8) !void { + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o600)); + try std.testing.expect(fd >= 0); + defer _ = libc.close(fd); + try std.testing.expectEqual(@as(isize, @intCast(bytes.len)), libc.write(fd, bytes.ptr, bytes.len)); +} + test "monospaced reads the advances out of a real sfnt layout" { // A whole font in 92 bytes: the header, a two-record table directory, and // an hhea + hmtx that between them say "three glyphs, all 600 units wide". @@ -269,23 +327,79 @@ test "monospaced reads the advances out of a real sfnt layout" { std.mem.writeInt(u16, f[44 + 34 ..][0..2], 3, .big); // numberOfHMetrics for (0..3) |i| std.mem.writeInt(u16, f[80 + i * 4 ..][0..2], 600, .big); - const path = "/tmp/pardes-fonts-test.ttf"; - { - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o644)); - try std.testing.expect(fd >= 0); - defer _ = libc.close(fd); - try std.testing.expectEqual(@as(isize, f.len), libc.write(fd, &f, f.len)); - } + var path_buf: [64:0]u8 = undefined; + const path = scratchFont(&path_buf, ".ttf"); + defer _ = libc.unlink(path); + try writeScratch(path, &f); try std.testing.expect(monospaced(path)); // ...and one glyph a different width is the whole difference between a // font this can wear and one it cannot std.mem.writeInt(u16, f[80 + 4 ..][0..2], 1200, .big); - { - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o644)); - try std.testing.expect(fd >= 0); - defer _ = libc.close(fd); - try std.testing.expectEqual(@as(isize, f.len), libc.write(fd, &f, f.len)); - } + try writeScratch(path, &f); try std.testing.expect(!monospaced(path)); } + +test "a ttc collection is read through its first face" { + // The same 92-byte font as above, moved 20 bytes down the file behind a + // `ttcf` header naming two faces. Table offsets stay absolute, which is + // the property that makes this work at all and the one a hand-rolled + // "add the base" would silently break. + const base = 20; + var f: [base + 92]u8 = @splat(0); + @memcpy(f[0..4], "ttcf"); + std.mem.writeInt(u16, f[4..6], 1, .big); // majorVersion + std.mem.writeInt(u32, f[8..12], 2, .big); // numFonts + std.mem.writeInt(u32, f[12..16], base, .big); // face 0 lives here + std.mem.writeInt(u32, f[16..20], base, .big); // face 1, same tables + + const sfnt = f[base..]; + std.mem.writeInt(u32, sfnt[0..4], 0x00010000, .big); + std.mem.writeInt(u16, sfnt[4..6], 2, .big); + @memcpy(sfnt[12..16], "hhea"); + std.mem.writeInt(u32, sfnt[20..24], base + 44, .big); + @memcpy(sfnt[28..32], "hmtx"); + std.mem.writeInt(u32, sfnt[36..40], base + 80, .big); + std.mem.writeInt(u16, sfnt[44 + 34 ..][0..2], 3, .big); + for (0..3) |i| std.mem.writeInt(u16, sfnt[80 + i * 4 ..][0..2], 600, .big); + + var path_buf: [64:0]u8 = undefined; + const path = scratchFont(&path_buf, ".ttc"); + defer _ = libc.unlink(path); + try writeScratch(path, &f); + try std.testing.expect(monospaced(path)); + + // A collection claiming no faces has no first one to read. + std.mem.writeInt(u32, f[8..12], 0, .big); + try writeScratch(path, &f); + try std.testing.expect(!monospaced(path)); +} + +test "the walk finds this machine's monospace faces" { + // Not a fixture: the directory list is the entire platform-specific part + // of this file, and a wrong one produces an empty picker rather than an + // error. So this asserts against the machine — every OS pardes draws its + // own text on ships a monospace face, and finding NONE means the walk is + // looking somewhere that does not exist. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const found = list(arena_state.allocator(), null); + try std.testing.expect(found.len > 0); + for (found) |font| { + try std.testing.expect(font.name.len > 0); + try std.testing.expect(font.path[0] == '/'); + // The name is the stem, so the file it came from is always longer. + try std.testing.expect(std.fs.path.basename(font.path).len > font.name.len); + } + + // macOS ships Menlo, and ships it inside a .ttc. It is the one face this + // machine can be held to by name, and naming it here is what keeps the + // collection branch honest end to end: drop .ttc from the walk, or read a + // collection's directory at offset 0, and this is what notices. + if (comptime builtin.os.tag == .macos) { + const menlo = for (found) |font| { + if (std.mem.eql(u8, font.name, "Menlo")) break font; + } else return error.MenloMissing; + try std.testing.expect(std.mem.endsWith(u8, menlo.path, ".ttc")); + } +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a03e51ce..c21ad069 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -23,7 +23,7 @@ const look = @import("../look.zig"); const message = @import("../message.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); -const fonts = @import("fonts.zig"); // the Font builtin's half of the seam +const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); const is_emscripten = builtin.os.tag == .emscripten; diff --git a/src/macos.zig b/src/macos.zig index d463b842..d3f9577c 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -28,6 +28,10 @@ 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 nested = @import("nested.zig"); +const fonts = if (pardes.font_picker) @import("fonts.zig") else struct { + pub const want: ?[]const u8 = null; +}; 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; @@ -108,11 +112,15 @@ const Pty = struct { const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32 }, + /// One `Look <path>` line from a pardes launched inside this one. Arrives + /// on the listener thread; runs, like everything else, on the main one. + command: []u8, fn free(m: Msg, gpa: std.mem.Allocator) void { switch (m) { .output => |o| gpa.free(o.bytes), .eof => {}, + .command => |c| gpa.free(c), } } }; @@ -156,8 +164,11 @@ const Inbox = struct { return removed; } - /// Pty output is lossy under sustained backpressure. EOF is structural: - /// admit it by evicting queued output so dead readers are always reaped. + /// Pty output is lossy under sustained backpressure. EOF is structural, and + /// so is a nested `Look`: one is a reader that must be reaped, the other is + /// a launch that already exited believing it was delivered. Admit both by + /// evicting queued output. Every switch below is exhaustive on purpose — a + /// new message kind has to say which of the two it is. fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void { q.lock(); defer q.mutex.unlock(); @@ -166,11 +177,11 @@ const Inbox = struct { return; } if (q.len == q.items.len) { - const incoming_eof = switch (m) { - .eof => true, - .output => false, + const lossy = switch (m) { + .output => true, + .eof, .command => false, }; - if (!incoming_eof) { + if (lossy) { m.free(gpa); return; } @@ -178,7 +189,7 @@ const Inbox = struct { while (offset < q.len) : (offset += 1) if (switch (q.items[(q.head + offset) % q.items.len]) { .output => true, - .eof => false, + .eof, .command => false, }) break; if (offset == q.len) return; q.removeAt(offset).free(gpa); @@ -234,10 +245,30 @@ const State = struct { /// 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. + /// Sub-cell wheel distance the core has not been told about yet, one + /// accumulator per axis. The core moves a whole row or column at a time, + /// so fractional trackpad travel banks here and is spent as wheel presses + /// — see pardes_scroll. Separate axes because a diagonal drift must not + /// let one direction's residue push the other over a notch. scroll_lag: f32 = 0, + scroll_lag_x: f32 = 0, + /// Degrees of trackpad rotation not yet spent as a search step — the same + /// accumulate-and-keep-the-remainder shape as scroll_lag, see pardes_rotate. + rotate_lag: f32 = 0, + /// Panes whose shell has produced output since we last read its cwd. + /// + /// The cwd is wanted for pane tags and for resolving a relative Look, and + /// asking libproc costs a syscall per pane. Polling it on a clock spends + /// that forever to notice something that only ever changes when the shell + /// runs a command — and a shell that ran a command always writes at least + /// its next prompt. So the read is owed to output, not to time: mark here + /// on the way past and settle it once at the end of the drain, however + /// many chunks that burst arrived in. + cwd_stale: [pardes.MAX_PANES]bool = @splat(false), + /// The socket a pardes launched inside this app connects to (nested.zig), + /// or -1 when it could not be bound and nested launches open their own + /// window as they always did. + sock_fd: c_int = -1, /// Owns the bytes of the user config, which Options only borrows. config_arena: std.heap.ArenaAllocator, }; @@ -335,10 +366,52 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { // the two backends readable side by side. _ = drainEffects(st, false); for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id)); + + // Last, because it is the one thing here that publishes this process to + // the outside: nothing may connect before the core can answer. The shells + // above are already forked, which is why the listener's fd is CLOEXEC — + // an orphaned bash holding it would keep the socket bound after we quit. + st.sock_fd = nested.listen(); + if (st.sock_fd >= 0) { + const thread = std.Thread.spawn(.{}, lookServer, .{st}) catch |err| { + // Bound but unattended would be worse than never bound: every + // nested launch would connect, be believed, and vanish. + log.warn("nested Look server did not start ({t})", .{err}); + nested.unlisten(st.sock_fd); + st.sock_fd = -1; + return; + }; + thread.detach(); + } +} + +/// Accept `Look <path>` lines from pardes instances launched inside this app +/// and post them where the main thread will run them. +/// +/// A detached thread around a call that never returns, exactly like the tty +/// backend's: close(2) does not release a thread parked in accept(2), so this +/// dies with the process rather than with the socket. The window that leaves +/// is one connection accepted between the last tick and process exit posting +/// into an inbox nobody drains — the same bound the pty readers have, and a +/// self-pipe to close it would be more machinery than the window is worth. +fn lookServer(st: *State) void { + var buf: [nested.max_line]u8 = undefined; + while (nested.acceptLine(st.sock_fd, &buf)) |line| { + const owned = st.gpa.dupe(u8, line) catch continue; + st.inbox.push(st.gpa, .{ .command = owned }); + wake(st); + } } export fn pardes_deinit() void { const st = &(state orelse return); + // Before anything else: it is the only fd another process can reach us + // through, and unlinking the file is what stops the next launch from + // connecting to a session that is halfway through tearing itself down. + // The thread parked in accept(2) is not released by this and dies with + // the process, which is what its detach() already said. + nested.unlisten(st.sock_fd); + st.sock_fd = -1; // 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. @@ -369,9 +442,33 @@ export fn pardes_animating() bool { return st.core.themeAnimationActive(); } +/// Re-read the cwd of every shell that just spoke, and only those. +/// +/// A pane's tag shows this and a relative `Look` resolves against it, so it has +/// to follow the shell around rather than stay at the directory the pane was +/// spawned in. The tty and SDL hosts poll all of them every frame; here the +/// drain has just said exactly which shells produced bytes, and nothing else +/// can have changed one — a `cd` is a command, and a shell that ran a command +/// writes at least its next prompt. So an idle session costs nothing at all, +/// and a busy one costs one libproc call per pane per burst. +fn refreshCwds(st: *State) void { + for (&st.cwd_stale, 0..) |*stale, id| { + if (!stale.*) continue; + stale.* = false; + const pt = st.ptys[id] orelse continue; + var buf: [1024]u8 = undefined; + if (look.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd); + } +} + /// 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. +/// the core queued in response. Returns whether this tick did any IO. +/// +/// NOT a repaint signal, however tempting: the core changes the grid on its own +/// for a cursor move, a selection, a mode change and a scroll, none of which +/// queue an effect or read a pty, so all four return false here. The macOS host +/// learned that the expensive way — see the comment on pump() in +/// src/macos/Sources/AppDelegate.swift. export fn pardes_tick() bool { const st = &(state orelse return false); // Cleared before the drain: a reader that pushes during this tick must be @@ -384,6 +481,7 @@ export fn pardes_tick() bool { switch (msg) { .output => |o| { if (st.gens[o.pane] != o.gen) continue; + st.cwd_stale[o.pane] = true; st.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); }, .eof => |e| { @@ -394,8 +492,12 @@ export fn pardes_tick() bool { reap(st, e.pane); st.core.update(.{ .eof = .{ .pane = e.pane } }); }, + // Already filtered down to `Look ` by the accept side — this + // socket may open things and that is all it may do. + .command => |c| st.core.update(.{ .command = c }), } } + refreshCwds(st); 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. @@ -456,13 +558,12 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m } }); } -export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void { +export fn pardes_scroll(delta_rows: f32, delta_cols: 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; + var down_left = takeScrollTicks(&st.scroll_lag, delta_rows); + while (down_left != 0) { + const down = down_left > 0; + down_left += if (down) -1 else 1; st.core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, @@ -470,6 +571,48 @@ export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void { .row = row, } }); } + // Horizontal after vertical, and through the same quantizer: the core's + // own drift guard (config.wheelTick) is what decides whether a sideways + // wobble during a vertical flick counts, so the shell must not second-guess + // it by filtering here. + var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols); + while (right_left != 0) { + const right = right_left > 0; + right_left += if (right) -1 else 1; + st.core.update(.{ .mouse = .{ + .button = if (right) .wheel_right else .wheel_left, + .kind = .press, + .col = col, + .row = row, + } }); + } +} + +/// Spend a trackpad rotation as search steps. AppKit reports degrees since the +/// last event, counterclockwise positive; the core has no rotation, so the +/// dial is quantized into the keys a hand would otherwise press — clockwise is +/// `n` (forward through the matches), counterclockwise `N`. +export fn pardes_rotate(degrees: f32) void { + const st = &(state orelse return); + // A gesture beginning re-zeros the dial: leftover travel from the last + // twist must not make the first degree of this one jump a match. + if (degrees == 0) { + st.rotate_lag = 0; + return; + } + var left = takeRotationNotches(&st.rotate_lag, degrees); + while (left != 0) { + const back = left > 0; // counterclockwise + left += if (back) -1 else 1; + st.core.update(.{ .key = .{ .cp = if (back) 'N' else 'n' } }); + } +} + +export fn pardes_command(text_ptr: ?[*]const u8, len: usize) void { + const st = &(state orelse return); + const text: []const u8 = if (text_ptr) |p| p[0..len] else ""; + if (text.len == 0) return; + st.core.update(.{ .command = text }); } export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) void { @@ -561,6 +704,38 @@ export fn pardes_cursor_bar() bool { return if (st.core.surface.cursor) |c| c.bar else false; } +/// The acme verb the core last performed, and clears it. Ordinals, not the +/// enum: the host is not part of this build, so the boundary speaks integers +/// and the ABI guard asserts they are the ones the header names. +export fn pardes_take_haptic() c_int { + const st = &(state orelse return 0); + return switch (st.core.takeHaptic()) { + .none => 0, + .exec => 1, + .look => 2, + }; +} + +/// The file the `Font` builtin asked for, and clears it — the same take-once +/// shape as the haptic above, and the same one the SDL shell uses on this +/// exact variable. +/// +/// A copy rather than the borrowed slice: `fonts.want` is a length and no +/// terminator, and C wants a string. One static buffer because there is one +/// core and the header promises the value only until the next call. +var font_path_z: [4096:0]u8 = undefined; + +export fn pardes_font_take() ?[*:0]const u8 { + _ = state orelse return null; + if (comptime !pardes.font_picker) return null; + const want = fonts.want orelse return null; + fonts.want = null; + if (want.len >= font_path_z.len) return null; + @memcpy(font_path_z[0..want.len], want); + font_path_z[want.len] = 0; + return &font_path_z; +} + // ---------------------------------------------------------------- effects /// Perform the IO the core queued. `threads_ok` is false for the one drain @@ -826,6 +1001,25 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 { return whole; } +/// One search step per this many degrees of twist. A trackpad rotation runs +/// tens of degrees before it feels deliberate, and every notch here is a jump +/// to another match — coarse on purpose, so a thumb resettling cannot walk the +/// cursor across the file. +const rotation_notch_degrees: f32 = 20; + +/// Spend accumulated rotation as whole search steps, keeping the remainder. +/// Same contract as takeScrollTicks, including the clamp: an absurd delta +/// spends a bounded number of notches instead of spinning the emit loop. +fn takeRotationNotches(lag: *f32, degrees: f32) i32 { + if (!std.math.isFinite(degrees)) return 0; + const limit = rotation_notch_degrees * 64; + const next = std.math.clamp(lag.* + degrees, -limit, limit); + if (!std.math.isFinite(next)) return 0; + const whole: i32 = @intFromFloat(@trunc(next / rotation_notch_degrees)); + lag.* = next - @as(f32, @floatFromInt(whole)) * rotation_notch_degrees; + return whole; +} + // ---------------------------------------------------------------- ABI guard // The header is hand-written, so nothing but a test keeps it honest. build.zig @@ -857,6 +1051,8 @@ test "pardes.h declares every export the way it is defined" { 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_rotate), @TypeOf(pardes_rotate)); + try expectSameAbi(@TypeOf(c.pardes_command), @TypeOf(pardes_command)); 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)); @@ -865,6 +1061,8 @@ test "pardes.h declares every export the way it is defined" { 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)); + try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic)); + try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take)); } test "pardes.h matches the Zig boundary" { @@ -914,6 +1112,12 @@ test "pardes.h matches the Zig boundary" { try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag)); + // The haptic ordinals pardes_take_haptic returns, against the header's + // names and the core's enum. Three places, checked as one. + try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none)); + try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec)); + try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look)); + // 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 })); @@ -957,3 +1161,30 @@ test "sub-row scroll spends whole notches and keeps the remainder" { try expectEqual(@as(f32, 0), lag); try expectEqual(@as(i32, 256), takeScrollTicks(&lag, 1e9)); } + +test "trackpad rotation spends whole search steps and keeps the remainder" { + const expectEqual = std.testing.expectEqual; + var lag: f32 = 0; + // A twist under one notch moves nothing; crossing it moves exactly one, + // and the overshoot is credited to the next. + try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 15)); + try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 10)); + try expectEqual(@as(f32, 5), lag); + + // Reversing spends the residue first, so a twist back is not amplified by + // travel the other direction already banked. + try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -25)); + try expectEqual(@as(f32, 0), lag); + + // One deliberate half-turn is several matches, not a hundred. + lag = 0; + try expectEqual(@as(i32, 9), takeRotationNotches(&lag, 180)); + + // Garbage moves nothing and leaves the dial usable; an absurd delta is + // clamped rather than spinning the emit loop. + lag = 0; + try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32))); + try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32))); + try expectEqual(@as(f32, 0), lag); + try expectEqual(@as(i32, 64), takeRotationNotches(&lag, 1e9)); +} diff --git a/src/macos/Info.plist b/src/macos/Info.plist index 9e8f65f7..665d54ca 100644 --- a/src/macos/Info.plist +++ b/src/macos/Info.plist @@ -10,11 +10,67 @@ <string>pardes</string> <key>CFBundlePackageType</key> <string>APPL</string> + <!-- The version the About panel shows, and the build Launch Services + compares when two copies of the bundle are on disk. --> + <key>CFBundleShortVersionString</key> + <string>0.1.0</string> + <key>CFBundleVersion</key> + <string>1</string> + <!-- No extension: Launch Services appends .icns and looks in + Contents/Resources, where build-app.sh emits pardes.icns. Without this + key the Dock shows the blank generic app even though the icon is + sitting right there in the bundle. --> + <key>CFBundleIconFile</key> + <string>pardes</string> <key>NSHighResolutionCapable</key> <true/> + <!-- build-app.sh overwrites this from macos_min_version in build.zig, which + is the single source of truth; the key must exist for PlistBuddy's Set + to have something to set. --> <key>LSMinimumSystemVersion</key> <string>13.0</string> + <key>LSApplicationCategoryType</key> + <string>public.app-category.developer-tools</string> <key>NSPrincipalClass</key> <string>NSApplication</string> + <!-- Both false, and neither is boilerplate: there are live ptys with child + processes attached and unsaved buffers with no autosave behind them, so + a process macOS killed or quietly relaunched would lose work and orphan + shells. --> + <key>NSSupportsAutomaticTermination</key> + <false/> + <key>NSSupportsSuddenTermination</key> + <false/> + <!-- What Finder's "Open With" offers pardes for. Editor rather than Viewer + because Save is a builtin: pardes writes the files it opens. A folder + is a legitimate target too — Look on a directory opens a directory + pane, which is how you navigate in acme. --> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeName</key> + <string>Text Document</string> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.plain-text</string> + </array> + </dict> + <dict> + <key>CFBundleTypeName</key> + <string>Folder</string> + <key>CFBundleTypeRole</key> + <string>Editor</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.folder</string> + </array> + </dict> + </array> </dict> </plist> diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 4df5899c..f5ca4080 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -1,16 +1,16 @@ 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. +// machine, the ptys and every worker thread; this file owns the window, the +// menu bar, 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") +// delegate a third method for "I just fed the core", the view posts +// pardesDidInputNotification after every input call and we answer it with +// pump(). That name is declared once, in PardesView.swift, precisely so the two +// files cannot drift apart on a string literal. final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { private var window: NSWindow! @@ -20,12 +20,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // 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 + // The core is a singleton with no "is it alive" query, and Finder can hand + // us documents before applicationDidFinishLaunching runs. Every entry point + // that would call into libpardes from outside the launch sequence checks + // this first; opens that arrive early queue below instead of crashing in a + // core that has not been constructed. + private var coreIsUp: Bool = false + private var pendingOpens: [URL] = [] func applicationDidFinishLaunching(_ notification: Notification) { + // Captured before the chdir below, because `pardes ./foo.zig` from a + // terminal means foo.zig in the shell's directory, not in $HOME. + let launchDirectory = FileManager.default.currentDirectoryPath + + // A bundle launched from Finder, the Dock or `open(1)` inherits cwd `/` + // — launchd's, not any shell's — so the first pane's shell would start + // at the root of the disk and every relative path the user types would + // resolve there. A binary run from a terminal inherits that terminal's + // directory, which is already what was meant, so only the `/` case is + // corrected: anything else is somebody's deliberate choice. + if launchDirectory == "/" { + FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory()) + } + + installMainMenu() + // 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) + view = PardesView(fontSize: defaultFontSize) let want = NSSize(width: 1000, height: 700) window = NSWindow( @@ -42,14 +65,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // 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() + // Autosave after the snap, never before: on a first run there is no + // saved frame and the window must still open at the snapped default, + // and registering the name first would have AppKit write the unsnapped + // 1000x700 out as the remembered geometry. + window.setFrameAutosaveName("pardes") + // setFrameAutosaveName only arms the saving half; the restore is this + // call, and it reports whether there was anything to restore. Both must + // happen before pardes_init, because the grid we boot the core with has + // to be the grid the window actually ends up at. + if !window.setFrameUsingName("pardes") { + window.center() + } + // Resize in whole cells. Increments are measured from the window's + // current size rather than from zero, which is why the snap above still + // matters: without it every drag would land a half-column short of the + // frame, and the view would draw a strip it can never put a glyph in. + window.contentResizeIncrements = NSSize(width: view.cellWidth, height: view.cellHeight) + // One core per process, so a second tab would be an empty window with + // no grid behind it. macOS offers tabs on any titled resizable window + // unless told otherwise. + window.tabbingMode = .disallowed + // The same constant PardesView paints as defaultBG. Two places name it + // because two different things draw: the view fills its own bounds, and + // AppKit fills the titlebar and every pixel of a live resize the view + // has not caught up with yet — without this that gap flashes white on + // every drag. The day the core exposes its theme background over the + // ABI, both should read it instead of agreeing by hand. + window.backgroundColor = NSColor(srgbRed: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255, alpha: 1) + // A dark window with a light-mode titlebar reads as a bug. This also + // gets the traffic-light glyphs and the resize cursor right. + window.appearance = NSAppearance(named: .darkAqua) // 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 @@ -60,14 +109,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // 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. + // activate(ignoringOtherApps:) is deprecated in favour of activate() on + // the 14 SDK, but activate() does not exist at our 13.0 deployment + // target and the deprecation does not fire below it. This is the call + // to change the day macos_min_version reaches 14. NSApp.activate(ignoringOtherApps: true) var runtime = pardes_runtime_s( @@ -104,13 +149,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // would call pardes_deinit on a core that never came up. exit(1) } + coreIsUp = true // 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) + self, selector: #selector(inputArrived(_:)), name: pardesDidInputNotification, 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, @@ -118,6 +164,236 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // core compares the effective pixel viewport, not the resize event, so // duplicate SIGWINCH-shaped notifications are already a no-op. pardesViewDidResize(view) + + // Positional paths, after the first frame's worth of state exists. + // Anything starting with `-` is skipped rather than opened: the core + // has no flags yet, and Finder itself passes `-psn_0_...` on some + // launch paths, which would otherwise become a Look for a file that + // does not exist. + for argument in CommandLine.arguments.dropFirst() where !argument.hasPrefix("-") { + look(absolutePath(of: argument, relativeTo: launchDirectory)) + } + + // Documents that Finder handed us before the core existed. + let queued = pendingOpens + pendingOpens = [] + for url in queued { + look(url.path) + } + } + + // Finder double-click, Dock drop, `open -a pardes file`, and "Open With". + // The URL-array form rather than application(_:openFile:), which has been + // deprecated since 10.13 and only ever reports one path at a time. + func application(_ application: NSApplication, open urls: [URL]) { + // A non-file URL would arrive here only through a URL scheme we do not + // register, but Look wants a path and would treat the scheme prefix as + // part of one. + let files = urls.filter { $0.isFileURL } + guard coreIsUp else { + // Finder sends these between applicationWillFinishLaunching and + // applicationDidFinishLaunching, so they arrive before the core is + // constructed and are replayed at the end of launch. They land + // after the argv paths, which costs nothing: each path is an + // independent Look and only the last one takes focus. + pendingOpens.append(contentsOf: files) + return + } + for url in files { + look(url.path) + } + } + + // ---------------------------------------------------------------- menu + + // Built in code rather than loaded from a nib. There is no Xcode project + // here (see docs/macos.md), so a MainMenu.xib would be a binary blob nobody + // in this tree can edit; the menu is thirty lines of Swift instead. + // + // Every item that reaches the core goes through run(), which is + // pardes_command — the core's own `command` event, the same channel a + // pardes nested inside another one speaks over. So a menu item is exactly + // the text you could have typed into a tag and executed, and there is no + // second vocabulary to keep in step with builtins.zig. + // + // The cost of having a menu at all is that these chords are now the menu's + // and the core can never see them: Cmd+Q, Cmd+H, Opt+Cmd+H, Cmd+O, Cmd+N, + // Cmd+S, Cmd+W, Cmd+V, Cmd+M and Cmd+?. AppKit offers a key equivalent to + // the main menu before the event ever reaches the key window. Every other + // Cmd chord still dies in PardesView, which swallows them because the ABI's + // modifier mask has ctrl, alt and shift and no super bit — a Cmd chord + // handed to the core would arrive as its unmodified letter and type itself + // into the buffer. + private func installMainMenu() { + let appName = ProcessInfo.processInfo.processName + let main = NSMenu() + + // AppKit treats the first top-level item as the application menu + // whatever it is titled, and substitutes the bundle name in bold. + let app = submenu(appName, of: main) + app.addItem(withTitle: "About \(appName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "") + app.addItem(.separator()) + app.addItem(withTitle: "Hide \(appName)", + action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthers = app.addItem(withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + app.addItem(withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + app.addItem(.separator()) + app.addItem(withTitle: "Quit \(appName)", + action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + + let file = submenu("File", of: main) + file.addItem(command("Open\u{2026}", #selector(menuOpen(_:)), "o")) + file.addItem(command("New", #selector(menuNew(_:)), "n")) + file.addItem(command("Save", #selector(menuSave(_:)), "s")) + file.addItem(.separator()) + // performClose: goes to the key window through the responder chain, so + // it keeps working if this app ever grows a panel. + file.addItem(withTitle: "Close Window", + action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + + let edit = submenu("Edit", of: main) + // Paste and nothing else. Copy, Cut, Undo and Select All have no + // builtin behind them — the core yanks into the clipboard on its own + // selection gestures and has no undo command to call — and a greyed or + // silently dead menu item teaches the user the menu lies. When the core + // grows those commands they belong here, spelled as run("...") like + // everything else. + edit.addItem(command("Paste", #selector(menuPaste(_:)), "v")) + + // The only menu whose items are not core commands: the font size is a + // property of this shell and nothing else. `Font` picks the FACE and + // lives in the topbar with the other builtins; the size is where the + // window is, so it is where macOS keeps it. + let viewMenu = submenu("View", of: main) + // Cmd+= rather than Cmd++: the key is unshifted `=` on every layout + // that has a `+` above it, and AppKit matches the character, not the + // engraving. The item is titled with the plus users look for. + viewMenu.addItem(command("Zoom In", #selector(menuZoomIn(_:)), "=")) + viewMenu.addItem(command("Zoom Out", #selector(menuZoomOut(_:)), "-")) + viewMenu.addItem(command("Actual Size", #selector(menuZoomReset(_:)), "0")) + + let windowMenu = submenu("Window", of: main) + windowMenu.addItem(withTitle: "Minimize", + action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + + let help = submenu("Help", of: main) + // Cmd+? is the system-wide help chord; AppKit draws it as the shift of + // Cmd+/ without being told about the shift. + help.addItem(command("\(appName) Help", #selector(menuHelp(_:)), "?")) + help.addItem(command("Tutorial", #selector(menuTutor(_:)), "")) + + NSApp.mainMenu = main + // Handing AppKit these two makes it keep the window list up to date and + // put the Help search field at the top of the Help menu. Both must come + // after mainMenu is assigned, or AppKit has nothing to attach them to. + NSApp.windowsMenu = windowMenu + NSApp.helpMenu = help + } + + private func submenu(_ title: String, of parent: NSMenu) -> NSMenu { + let holder = NSMenuItem(title: title, action: nil, keyEquivalent: "") + // The submenu's own title is what AppKit shows in the menu bar for the + // Window and Help menus, which it looks up by title rather than by the + // item that holds them. + let menu = NSMenu(title: title) + holder.submenu = menu + parent.addItem(holder) + return menu + } + + // An item that runs one of our own actions. Explicitly targeted at self + // rather than left to the responder chain: PardesView is the first + // responder and answers none of these, and an untargeted item that finds no + // handler renders greyed out. + private func command(_ title: String, _ action: Selector, _ key: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.target = self + return item + } + + @objc private func menuOpen(_ sender: Any?) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + // Directories are first-class Look targets — the core opens one as a + // directory pane, which is how you navigate in acme — so refusing them + // here would hide half of what Look does. + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.resolvesAliases = true + guard panel.runModal() == .OK else { return } + for url in panel.urls where url.isFileURL { + look(url.path) + } + } + + @objc private func menuNew(_ sender: Any?) { run("New") } + @objc private func menuSave(_ sender: Any?) { run("Save") } + @objc private func menuHelp(_ sender: Any?) { run("Help") } + @objc private func menuTutor(_ sender: Any?) { run("Tutor") } + + // Deliberately the view's paste path and not a second one: Cmd+V from the + // menu and Cmd+V in the view must put the same bytes in through + // pardes_paste, or the two would diverge the first time either grows a + // filter. + @objc private func menuPaste(_ sender: Any?) { + guard coreIsUp else { return } + pardesViewRequestsPaste(view) + } + + // Zoom does not go through the core at all: it changes the cell, the view + // reports the new grid, and the core reflows to it exactly as it does for + // a window drag. Guarded on coreIsUp only because the resize callback it + // triggers calls into libpardes. + @objc private func menuZoomIn(_ sender: Any?) { + guard coreIsUp else { return } + view.zoom(by: 1) + } + + @objc private func menuZoomOut(_ sender: Any?) { + guard coreIsUp else { return } + view.zoom(by: -1) + } + + @objc private func menuZoomReset(_ sender: Any?) { + guard coreIsUp else { return } + view.zoomReset() + } + + // ---------------------------------------------------------------- core + + // One builtin command line, run as if it had been typed into a tag and + // executed. `command` is bridged to a temporary NUL-terminated UTF-8 buffer + // that lives exactly as long as the call, which is exactly as long as the + // core borrows it. + private func run(_ command: String) { + guard coreIsUp else { return } + pardes_command(command, command.utf8.count) + pump() + } + + // Look's operand is the whole tail of the line (executeBuiltinLine in + // src/pardes.zig splits on the first space and trims the rest), so a path + // with spaces in it needs no quoting and must not get any — quotes would + // become part of the filename. + private func look(_ path: String) { run("Look \(path)") } + + // argv paths are whatever the shell handed us. The core resolves a relative + // Look against the pane's directory, not the process's, so `pardes + // ./foo.zig` would open the wrong foo.zig — or nothing — unless it is made + // absolute here against the directory the process was launched from. + private func absolutePath(of argument: String, relativeTo directory: String) -> String { + let expanded = (argument as NSString).expandingTildeInPath + guard !(expanded as NSString).isAbsolutePath else { + return (expanded as NSString).standardizingPath + } + return ((directory as NSString).appendingPathComponent(expanded) as NSString).standardizingPath } @objc private func inputArrived(_ notification: Notification) { @@ -128,7 +404,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // the effects the core queued, so nothing the user did takes hold until it // runs. private func pump() { - if pardes_tick() { view.needsDisplay = true } + // Unconditionally dirty, and NOT `if pardes_tick()`. That return value + // answers "did I do any IO" — it is true when a pty produced bytes or + // an effect was performed, and false for everything the core changes on + // its own. A cursor moved with j, a selection, a mode change and a + // scroll all queue nothing and perform nothing, so gating the repaint + // on it leaves the screen showing the state before the keystroke until + // some unrelated event happens to force a frame. Measured: four `j` + // presses in a file pane produced a byte-identical screenshot. + // + // The waste is bounded and small. AppKit coalesces needsDisplay within + // a runloop pass, so a burst of pty output is still one frame, and an + // idle wakeup cannot happen — a reader only wakes the host after it has + // read bytes. + _ = pardes_tick() + view.needsDisplay = true + + // Two verbs, two patterns, because they are two different answers: + // Exec did something, so it gets .generic, the definite tap of a + // committed action; Look went somewhere, so it gets .alignment, the + // lighter detent AppKit uses when a dragged guide snaps into place. + // Same distinction the core draws in Haptic (src/pardes.zig). + // + // No capability check on purpose. perform() is a silent no-op on a Mac + // with no Force Touch trackpad and when the user has turned feedback + // off in System Settings, so a check here would only be a second place + // to get the answer wrong — and it would be wrong the moment an + // external trackpad is plugged in mid-session. + let pulse = pardes_take_haptic() + if pulse == PARDES_HAPTIC_EXEC { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) + } else if pulse == PARDES_HAPTIC_LOOK { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + } + + // Beside the haptic, and for the same reason: the `Font` builtin ran + // synchronously inside whatever input reached the core, so its answer + // is already waiting by the time the tick returns. The view re-measures + // and calls back through pardesViewDidResize, so the grid the core is + // holding follows the cell that just changed size. + if let wanted = pardes_font_take() { + view.adoptFont(path: String(cString: wanted)) + } + if pardes_should_quit() { NSApp.terminate(nil) return @@ -173,6 +491,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { return true } + // Nothing in this app's UI state can be restored: the window is one view + // over a core that has to replay its own session, and the frame is already + // handled by setFrameAutosaveName. Answering true opts into the secure + // coder AppKit wants; leaving the method out makes macOS 14 and later log a + // deprecation warning on every launch. + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + func applicationWillTerminate(_ notification: Notification) { pardes_deinit() } diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 2dfa4305..c7152bd3 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -4,7 +4,17 @@ // 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. +// measure and never change — plus the two things a gesture needs remembered +// between events: which button a click started with, and how hard it is being +// pressed. +// +// Every NSEvent override decodes and then calls one of the post-decode entry +// points below (press/release/drag/click/scroll/rotate/typeKey). That split is +// not decoration: NSTouch, pressure stages and rotation have no public +// constructors, so a test can never synthesize them, and the only way the +// trackpad behaviour is reachable by anything but a finger is for the decision +// to live one call below the event. test/macos_e2e.swift drives exactly those +// entry points. // // Every C constant below is wrapped in an explicit conversion (UInt16(...), // UInt32(...)) rather than used bare. A macro's imported Swift type is decided @@ -13,18 +23,65 @@ import AppKit import CoreText +/// Posted after every call this view makes into the core, and answered by +/// AppDelegate.pump(). The core only queues what it was told and does nothing +/// until it is pumped, so an input without one of these is an input that +/// visibly did nothing. Declared here, where the posts are. +let pardesDidInputNotification = Notification.Name("pardesDidInput") + +/// A cell, already clamped to the frame the core last rendered. +struct GridPoint { + var col: UInt16 + var row: UInt16 +} + protocol PardesViewDelegate: AnyObject { func pardesViewDidResize(_ view: PardesView) func pardesViewRequestsPaste(_ view: PardesView) } +/// What a click means, from the fingers resting on the trackpad and the button +/// stream AppKit chose to deliver it on. Pure on purpose: NSTouch cannot be +/// constructed, so this is the part of the gesture a test can reach. +/// +/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes, +/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS +/// itself spells "secondary", so it is Look; three is the one left over, so it +/// is Exec, the heavier verb, which is also what a deep press means. +/// +/// The finger count has to win over the stream, and that is not a preference. +/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it +/// on, a three-finger click is delivered as rightMouseDown exactly like a +/// two-finger one, and a view that trusts the stream cannot tell them apart — +/// three fingers silently did Look. Measured on real hardware, which is the +/// only way this was ever going to be found: `rightMouseDown: resting=3`. +/// +/// So the stream is only the fallback, for when there are no fingers to count: +/// a real mouse's right button is Look and its middle button is Exec, and both +/// arrive with an empty touch set. +enum Trackpad { + static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e { + switch fingers { + case 2: return PARDES_MOUSE_RIGHT + case 3...: return PARDES_MOUSE_MIDDLE + // One finger, or none to count: the stream is the answer. A trackpad + // single click comes in on the left stream and stays left; a real + // mouse's right and middle buttons keep their acme meanings. + default: return stream + } + } + + /// A force click is a deliberate second gesture on top of an ordinary one, + /// so it gets the verb that does something rather than the one that + /// navigates. + static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE +} + // 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") +let pardesDefaultFG: UInt32 = 0xCC_CC_CC +let pardesDefaultBG: UInt32 = 0x12_12_12 // UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is // the same string as .font, but spelling the CoreText key means the value stays @@ -60,6 +117,16 @@ private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 { return encoded & UInt32(PARDES_COLOR_RGB_MASK) } +/// The four faces, indexed by the two attribute bits that pick one. Also the +/// glyph cache's first key, which is why it is an ordinal and not four fields. +private enum Face: Int, CaseIterable { + case regular = 0, bold = 1, italic = 2, boldItalic = 3 + + init(bold: Bool, italic: Bool) { + self = Face(rawValue: (bold ? 1 : 0) | (italic ? 2 : 0))! + } +} + /// `block` means the filled cursor sits on this cell. private func resolve( _ cell: pardes_cell_s, @@ -68,10 +135,12 @@ private func resolve( // 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) + return block + ? (pardesDefaultBG, pardesDefaultFG, 1, false) + : (pardesDefaultFG, pardesDefaultBG, 1, false) } - var fg = decodeColor(cell.fg, defaultFG) - var bg = decodeColor(cell.bg, defaultBG) + var fg = decodeColor(cell.fg, pardesDefaultFG) + var bg = decodeColor(cell.bg, pardesDefaultBG) // 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. @@ -97,6 +166,113 @@ private func advance(_ font: CTFont, _ character: UniChar) -> CGFloat { return size.width } +/// The size the window opens at and Cmd+0 returns to. Named here rather than +/// passed in because zoomReset has to know it too, and two spellings of one +/// number is how "actual size" stops being the size it actually opened at. +let defaultFontSize: CGFloat = 14 + +/// Everything that changes when the face or its size does, in one value so +/// that changing either is one assignment and cannot leave half the numbers +/// describing the old font. +/// +/// Built at init and again for a `Font` command or a zoom. The glyph caches +/// belong here for the same reason: a CGGlyph is an index into a particular +/// face, so carrying one across a font change draws the wrong character +/// rather than none. +private struct Metrics { + let fonts: [CTFont] + let ascent: CGFloat + let cellWidth: CGFloat + let cellHeight: CGFloat + let ruleThickness: CGFloat + let underlineOffset: CGFloat + /// ASCII is very nearly the whole screen, so its glyphs are resolved once + /// per face here and never looked up again. + let asciiGlyphs: [[CGGlyph]] + + init(size: CGFloat, path: String?) { + let face = Metrics.face(size: size, path: path) + + // 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, size, nil, traits, traits) ?? face + } + let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])] + + // Whole points, rounded UP. A fractional cell width puts every column + // boundary on a fraction of a pixel, and with antialiasing off — which + // the background pass needs, or touching fills seam — the rounding + // wobbles by a pixel from column to column. On a screen made of tag + // bars and selections that stripe is visible. Rounding up rather than + // to nearest because down can clip a glyph, and a slightly airy grid is + // not a bug. + fonts = faces + ascent = CTFontGetAscent(face) + cellWidth = max(1, advance(face, 0x4D).rounded(.up)) + cellHeight = max(1, (CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up)) + ruleThickness = max(1, CTFontGetUnderlineThickness(face)) + underlineOffset = CTFontGetUnderlinePosition(face) + asciiGlyphs = faces.map { font in + var chars = Array(UniChar(0)..<UniChar(128)) + var glyphs = [CGGlyph](repeating: 0, count: 128) + _ = CTFontGetGlyphsForCharacters(font, &chars, &glyphs, 128) + return glyphs + } + } + + /// The regular cut to build the other three from: the file the core asked + /// for, or the system monospace face when it asked for nothing — or when + /// what it asked for turned out not to be wearable. + private static func face(size: CGFloat, path: String?) -> CTFont { + if let path, let picked = Metrics.fromFile(path, size) { return picked } + let system = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + // Through the descriptor, not through CTFontCreateWithName(fontName): + // the system monospace face has a dot-prefixed internal name that a + // by-name lookup can miss entirely, and NSFontDescriptor is toll-free + // bridged, so this cannot resolve to a different font than AppKit just + // handed us. + let face = CTFontCreateWithFontDescriptor(system.fontDescriptor as CTFontDescriptor, size, nil) + // The whole layout is a fixed grid, so a proportional face is not a + // cosmetic problem, it is a broken screen. "M" and "i" disagreeing on + // advance is the cheapest possible proof that we got one. + return Metrics.isFixedPitch(face) ? face : CTFontCreateWithName("Menlo" as CFString, size, nil) + } + + /// A face out of a font FILE, which is what the core hands over — it found + /// the path by walking the font directories itself, so nothing here asks + /// CoreText to resolve a name that a different shell might resolve + /// differently. + /// + /// Nil rather than a substitute for anything wrong with the file, because + /// the caller's fallback is the face already on screen: a font that cannot + /// be measured would otherwise leave a terminal with no way back out. + private static func fromFile(_ path: String, _ size: CGFloat) -> CTFont? { + let url = URL(fileURLWithPath: path) as CFURL + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url) as? [CTFontDescriptor], + !descriptors.isEmpty else { return nil } + // A .ttc holds a family's four cuts in one file. Take the one with + // neither trait set — the regular — because the bold and italic ones + // are derived from it below; falling back to the first face keeps a + // collection whose cuts are all styled from being unusable. + let plain = descriptors.first { descriptor in + let traits = CTFontDescriptorCopyAttribute(descriptor, kCTFontTraitsAttribute) as? [CFString: Any] + let symbolic = (traits?[kCTFontSymbolicTrait] as? UInt32) ?? 0 + return symbolic & UInt32(CTFontSymbolicTraits.traitBold.rawValue | CTFontSymbolicTraits.traitItalic.rawValue) == 0 + } + let face = CTFontCreateWithFontDescriptor(plain ?? descriptors[0], size, nil) + // The core already filtered for fixed pitch by reading the file's own + // advances. This is the same question asked of the face CoreText + // actually built, which is the one that will be drawn with. + return Metrics.isFixedPitch(face) ? face : nil + } + + private static func isFixedPitch(_ face: CTFont) -> Bool { + let em = advance(face, 0x4D) + return em > 0 && abs(em - advance(face, 0x69)) <= 0.01 + } +} + private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { var mods: UInt32 = 0 if flags.contains(.control) { mods |= UInt32(PARDES_MOD_CTRL) } @@ -106,59 +282,135 @@ private func modifiers(_ flags: NSEvent.ModifierFlags) -> UInt32 { } 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 + // The face and the numbers off it, replaced whole by `wear`. + private var metrics: Metrics + /// What `metrics` was built from, so a zoom keeps the face and a font + /// change keeps the size. + private var fontSize: CGFloat + private var fontPath: String? + + var cellWidth: CGFloat { metrics.cellWidth } + var cellHeight: CGFloat { metrics.cellHeight } + + // Glyphs the ASCII table above did not answer for. A stored 0 is .notdef, + // meaning "this face does not have it", which is a cache hit too — the + // CTLine fallback below is far more expensive than the lookup it would + // repeat. Keyed by face and codepoint, and thrown away with the face. + private var glyphCache: [UInt32: CGGlyph] = [:] + // Scratch for one batched run of glyphs. Held rather than made per row so a + // full redraw does not allocate 24 times. + private var runGlyphs: [CGGlyph] = [] + private var runPositions: [CGPoint] = [] + 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) - } + // The button a left-stream click actually started with. mouseDown decides + // it from the fingers on the trackpad, and mouseDragged/mouseUp must use + // the same one: a press of right followed by a release of left leaves the + // core holding a drag nothing will ever end. + private var latchedButton: pardes_mouse_button_e? + private var latchedCell: GridPoint? + // Force-click stage, reset per press. AppKit repeats stage-2 events for as + // long as the finger stays down, and only the transition is the gesture. + private var pressureStage: Int = 0 + // Fingers currently resting on the trackpad, kept from the touch stream. + // + // mouseDown was originally trusted to carry its own touch set, and on this + // hardware it does not always: AppKit routes NSTouch through the four + // touchesXxx callbacks, and the touch set hanging off a *mouse* event can + // come back empty depending on how the click was produced. Empty reads as + // one finger, which is a two-finger Look silently degrading into a select + // — the exact failure this was supposed to avoid. So the count is + // maintained here and the mouse event's own set is preferred only when it + // has something in it. + private var restingFingers: Int = 0 - // 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) + init(fontSize size: CGFloat) { + let built = Metrics(size: size, path: nil) + metrics = built + fontSize = size + fontPath = nil // 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)) + super.init(frame: NSRect(x: 0, y: 0, width: built.cellWidth * 80, height: built.cellHeight * 24)) + + runGlyphs.reserveCapacity(256) + runPositions.reserveCapacity(256) + // Indirect touches are the trackpad's. Without this the touch set is + // always empty and every click looks like one finger, which is exactly + // the bug that would make two-finger Look silently never fire. + allowedTouchTypes = [.indirect] + // Without a pressure configuration the deep-press stages are the + // system's business and stage 2 may never be delivered here. + // .primaryDeepClick is the one that means "a harder press is a second + // gesture", which is what it is being used for. + pressureConfiguration = NSPressureConfiguration(pressureBehavior: .primaryDeepClick) } required init?(coder: NSCoder) { fatalError("PardesView is built in code, not a nib") } + // MARK: - the face + + /// The PostScript name of the face on screen. The only way anything + /// outside this file can find out which font is being drawn with — the + /// core has no font, so a cell-buffer snapshot cannot see one. + var faceName: String { CTFontCopyPostScriptName(metrics.fonts[0]) as String } + + /// Put on a face, or the same face at a different size, and tell the host + /// the grid moved under it. + /// + /// A cell that changed size means a different number of columns fit the + /// same window, so this is a resize as far as the core is concerned — and + /// the delegate's resize path is already the one that reports both the + /// grid and the physical cell the PDF placement reads. Nothing here + /// second-guesses a file it could not load: `Metrics` falls back to the + /// system face, and asking for a font that is not wearable leaves the + /// screen exactly as it was rather than blank. + private func wear(size: CGFloat, path: String?) { + let next = Metrics(size: size, path: path) + // A CGGlyph is an index into a particular face. Kept across a change + // it would draw a different character, not a missing one. + glyphCache.removeAll(keepingCapacity: true) + metrics = next + fontSize = size + fontPath = path + delegate?.pardesViewDidResize(self) + needsDisplay = true + } + + /// The file `Font <name>` resolved to, straight from the core. + func adoptFont(path: String) { + wear(size: fontSize, path: path) + } + + /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole + /// points anyway: a tenth-of-a-point step would spend several keystrokes + /// landing on the same grid and look like the key had stopped working. + /// The range is what stays legible at the bottom and still fits a useful + /// number of columns at the top. + func zoom(by step: CGFloat) { + let next = min(max(fontSize + step, 6), 72) + guard next != fontSize else { return } + wear(size: next, path: fontPath) + } + + func zoomReset() { + guard fontSize != defaultFontSize else { return } + wear(size: defaultFontSize, path: fontPath) + } + // 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 } + // A click that focuses the window should also land in the grid: this is a + // text surface, and having to click twice after switching apps is the kind + // of thing that makes an app feel foreign. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } var gridSize: (cols: UInt16, rows: UInt16) { let cols = min(max((bounds.width / cellWidth).rounded(.down), 1), CGFloat(UInt16.max)) @@ -176,7 +428,7 @@ final class PardesView: NSView { let count = pardes_frame() let cols = Int(pardes_frame_cols()) let rows = Int(pardes_frame_rows()) - fill(ctx, bounds, defaultBG, 1) + fill(ctx, bounds, pardesDefaultBG, 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 @@ -215,20 +467,20 @@ final class PardesView: NSView { // 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) + // Every glyph sits at an exact multiple of cellWidth, so letting + // CoreText place it on a subpixel would blur a grid that is aligned by + // construction. Quantizing keeps the rasterizer's own cache hitting. + ctx.setShouldSubpixelPositionFonts(false) + ctx.setShouldSubpixelQuantizeFonts(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) - } + drawRow(ctx, cells, base: row * cols, cols: cols, + baseline: height - (CGFloat(row) * cellHeight + metrics.ascent), + blockCol: blockY == row ? blockX : -1) } ctx.restoreGState() @@ -238,73 +490,140 @@ final class PardesView: NSView { // 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 + ctx.setShouldAntialias(false) fill(ctx, CGRect(x: CGFloat(x) * cellWidth, y: CGFloat(y) * cellHeight, - width: max(1, cellWidth / 8), height: cellHeight), fg, 1) + width: max(1, (cellWidth / 8).rounded(.up)), height: cellHeight), fg, 1) + } + } + } + + /// One row of glyphs, batched. Consecutive cells that share a face and a + /// colour go to CoreText as a single call with a position array: a row of + /// plain text is then one draw instead of eighty, which is the difference + /// between a full redraw being free and being felt. + private func drawRow( + _ ctx: CGContext, + _ cells: UnsafePointer<pardes_cell_s>, + base: Int, + cols: Int, + baseline: CGFloat, + blockCol: Int + ) { + var runFace = Face.regular + var runColor: UInt32 = 0 + var runAlpha: CGFloat = 1 + runGlyphs.removeAll(keepingCapacity: true) + runPositions.removeAll(keepingCapacity: true) + + func flush() { + guard !runGlyphs.isEmpty else { return } + setFill(ctx, runColor, runAlpha) + CTFontDrawGlyphs(metrics.fonts[runFace.rawValue], runGlyphs, runPositions, runGlyphs.count, ctx) + runGlyphs.removeAll(keepingCapacity: true) + runPositions.removeAll(keepingCapacity: true) + } + + for col in 0..<cols { + let cell = cells[base + col] + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue } + let style = resolve(cell, block: blockCol == col) + let x = CGFloat(col) * cellWidth + + // Rules before the glyph, and independent of it: an underlined space + // is a real thing and so is an underlined invisible cell. They are + // fills, not glyphs, so they interrupt the run. + if cell.attrs >> UInt16(PARDES_ATTR_UL_SHIFT) != 0 + || cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 { + flush() + drawRules(ctx, cell, style, x: x, baseline: baseline) + } + guard style.visible else { continue } + + // 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 { continue } + + let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0, + italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0) + let units = text.utf16 + let known = units.count == 1 ? glyph(face, units.first!) : 0 + if known != 0 { + if !runGlyphs.isEmpty + && (face != runFace || style.fg != runColor || style.alpha != runAlpha) { + flush() + } + runFace = face + runColor = style.fg + runAlpha = style.alpha + runGlyphs.append(known) + runPositions.append(CGPoint(x: x, y: baseline)) + continue } + + // 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. + flush() + setFill(ctx, style.fg, style.alpha) + let attributed = NSAttributedString(string: text, attributes: [fontAttribute: metrics.fonts[face.rawValue]]) + ctx.textPosition = CGPoint(x: x, y: baseline) + CTLineDraw(CTLineCreateWithAttributedString(attributed as CFAttributedString), ctx) + // CTLineDraw leaves the text position at the END of what it drew, + // and textPosition IS the translation of the text matrix, which + // CTFontDrawGlyphs then applies to every position it is handed. So + // one fallback glyph silently displaces the entire rest of the + // frame by that glyph's advance, down and to the right — and since + // the wrap marker and the em dash take this path, that is most + // files. Put it back before anything else draws. + ctx.textMatrix = .identity } + flush() + } + + /// 0 is .notdef, i.e. "this face does not have it" — a real answer, cached + /// like any other, because the CTLine fallback it sends the caller to costs + /// far more than the lookup it would otherwise repeat every frame. + private func glyph(_ face: Face, _ character: UniChar) -> CGGlyph { + if character < 128 { return metrics.asciiGlyphs[face.rawValue][Int(character)] } + let key = UInt32(face.rawValue) << 16 | UInt32(character) + if let cached = glyphCache[key] { return cached } + var input = character + var found = CGGlyph(0) + _ = CTFontGetGlyphsForCharacters(metrics.fonts[face.rawValue], &input, &found, 1) + glyphCache[key] = found + return found } - private func drawCell( + private func drawRules( _ 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) + let y = baseline + metrics.underlineOffset + fill(ctx, CGRect(x: x, y: y, width: cellWidth, height: metrics.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), + fill(ctx, CGRect(x: x, y: y - metrics.ruleThickness * 2, width: cellWidth, height: metrics.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), + fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, width: cellWidth, height: metrics.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) { @@ -319,12 +638,78 @@ final class PardesView: NSView { ctx.fill(rect) } + // MARK: - the core, and the pump + + /// Everything below ends here. Posting the notification in one place is + /// what guarantees no entry point can feed the core and forget to ask for + /// the tick that performs it. + private func fed() { + NotificationCenter.default.post(name: pardesDidInputNotification, object: self) + } + + func typeKey(_ cp: UInt32, text: String, mods: UInt32) { + // The pointer is borrowed for the call and nowhere else, which is the only + // thing the header promises about it. + text.withCString { pardes_key(cp, $0, text.utf8.count, mods) } + fed() + } + + // `mods` defaults to none because a synthesized gesture carries no + // keyboard state; the NSEvent overrides always pass the real mask. Ctrl is + // the one the core actually consults — a left press with it held is + // goto-definition — so dropping it here would silently delete a feature. + func press(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods) + fed() + } + + func release(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_RELEASE, cell.col, cell.row, mods) + fed() + } + + func drag(_ button: pardes_mouse_button_e, to cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_DRAG, cell.col, cell.row, mods) + fed() + } + + func motion(to cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(PARDES_MOUSE_NONE, PARDES_MOUSE_MOTION, cell.col, cell.row, mods) + fed() + } + + /// A press and its release with nothing in between, which is what every + /// synthesized click is: a trackpad gesture we recognised rather than a + /// button the user held. + func click(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + press(button, at: cell, mods: mods) + release(button, at: cell, mods: mods) + } + + /// One discrete wheel notch, as opposed to the continuous travel below. + func wheel(_ button: pardes_mouse_button_e, at cell: GridPoint, mods: UInt32 = 0) { + pardes_mouse(button, PARDES_MOUSE_PRESS, cell.col, cell.row, mods) + fed() + } + + func scroll(rows: CGFloat, cols: CGFloat = 0, at cell: GridPoint) { + guard rows != 0 || cols != 0 else { return } + pardes_scroll(Float(rows), Float(cols), cell.col, cell.row) + fed() + } + + func rotate(degrees: CGFloat) { + guard degrees != 0 else { return } + pardes_rotate(Float(degrees)) + fed() + } + // 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 + // Anything the main menu claims never reaches here; the rest is swallowed // rather than delivered as the bare keystroke the core would insert. if flags.contains(.command) { if event.charactersIgnoringModifiers?.lowercased() == "v" { @@ -376,52 +761,179 @@ final class PardesView: NSView { // 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) + // Typing is the moment the pointer stops being interesting and starts + // sitting on top of the words. It comes back on the next mouse move. + NSCursor.setHiddenUntilMouseMoves(true) + typeKey(codepoint, text: text, mods: modifiers(flags)) + } + + // MARK: - trackpad + + // NSTouch arrives through these four and nowhere else. They are the only + // reliable source of "how many fingers are down right now": the touch set + // on a mouse event is an accident of how the click was produced, and an + // empty one is indistinguishable from one finger. + override func touchesBegan(with event: NSEvent) { countTouches(event) } + override func touchesMoved(with event: NSEvent) { countTouches(event) } + override func touchesEnded(with event: NSEvent) { countTouches(event) } + override func touchesCancelled(with event: NSEvent) { countTouches(event) } + + private func countTouches(_ event: NSEvent) { + restingFingers = event.touches(matching: .touching, in: nil).count + trace("touch: resting=\(restingFingers)") } // 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) } + // All three button streams land in the same three functions, because which + // stream a trackpad click arrives on is not something the app gets to know + // in advance: with macOS's secondary click on, two AND three fingers both + // come in as rightMouseDown. The button is therefore decided once, at the + // press, from the fingers plus the stream, and then LATCHED — the core is + // tracking a drag keyed by button, and answering a press of 3 with a + // release of 1 leaves it holding a sweep nothing will ever end. + // + // The count itself is maintained by the touchesXxx callbacks above; see + // beginClick for why it can only come from there. + private func beginClick(_ stream: pardes_mouse_button_e, _ event: NSEvent) { + guard let at = cell(for: event) else { return } + pressureStage = 0 + // The count comes from the touch stream and NEVER from the mouse event. + // Asking a mouse event for its touches is not merely unreliable, it + // raises: -[NSEvent touchesMatchingPhase:inView:] is defined for + // gesture and touch events, and on anything else AppKit throws, catches + // it inside its own event dispatch, and abandons the rest of this + // method. Nothing crashes and nothing is logged — every click just + // silently stops working, a plain drag included, while rotation and + // scrolling carry on as if the backend were fine. That is exactly how + // this presented, and it is why restingFingers exists. + let button = Trackpad.button(stream: stream, fingers: restingFingers) + trace("press: stream=\(stream.rawValue) fingers=\(restingFingers) -> button=\(button.rawValue)") + latchedButton = button + latchedCell = at + press(button, at: at, mods: modifiers(event.modifierFlags)) + } + + private func continueClick(_ event: NSEvent) { + guard let button = latchedButton, let at = cell(for: event) else { return } + latchedCell = at + drag(button, to: at, mods: modifiers(event.modifierFlags)) + } + + private func endClick(_ event: NSEvent) { + pressureStage = 0 + // Already nil when the force click below converted this press: it + // released the button itself and there is nothing left to end. + guard let button = latchedButton else { return } + latchedButton = nil + guard let at = cell(for: event) else { return } + latchedCell = at + release(button, at: at, mods: modifiers(event.modifierFlags)) + } + + override func mouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_LEFT, event) } + override func mouseDragged(with event: NSEvent) { continueClick(event) } + override func mouseUp(with event: NSEvent) { endClick(event) } + // Where a two-finger click lands with macOS's own secondary click on, and + // where a three-finger one lands too — hence the finger count in + // Trackpad.button rather than a hardcoded RIGHT here. + override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) } + override func rightMouseDragged(with event: NSEvent) { continueClick(event) } + override func rightMouseUp(with event: NSEvent) { endClick(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 otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) } + override func otherMouseDragged(with event: NSEvent) { continueClick(event) } + override func otherMouseUp(with event: NSEvent) { endClick(event) } + + /// A deep press, which is a second gesture layered on the click already in + /// flight. AppKit keeps sending stage-2 events while the finger stays down, + /// so only the transition counts. + /// + /// Whatever button is in flight is released before the middle one goes out: + /// a middle press arriving while the core holds a left select-drag is + /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at + /// the click point — which is what clicking there would have done anyway. + /// + /// Not gated on the press being a LEFT one, which is what stopped this + /// working: on a Force Touch trackpad the deep press is just as likely to + /// have arrived on the right stream, and an in-flight Look upgraded by + /// pressing harder is precisely the gesture. Already-Exec is the only case + /// with nothing to do. + override func pressureChange(with event: NSEvent) { + trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))") + guard pressureStage < 2 else { return } + pressureStage = event.stage + guard event.stage == 2 else { return } + guard let at = latchedCell, let current = latchedButton, + current != Trackpad.forceClickButton else { return } + latchedButton = nil + release(current, at: at, mods: modifiers(event.modifierFlags)) + click(Trackpad.forceClickButton, at: at, mods: modifiers(event.modifierFlags)) + } + + override func mouseMoved(with event: NSEvent) { + guard let at = cell(for: event) else { return } + motion(to: at, mods: modifiers(event.modifierFlags)) + } 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 + // The core scrolls a cell at a time, so libpardes accumulates the + // sub-cell 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 { + scroll(rows: -event.scrollingDeltaY / cellHeight, + cols: -event.scrollingDeltaX / cellWidth, + at: at) + } else { // 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 + if event.scrollingDeltaY != 0 { + wheel(event.scrollingDeltaY > 0 ? PARDES_MOUSE_WHEEL_UP : PARDES_MOUSE_WHEEL_DOWN, + at: at, mods: modifiers(event.modifierFlags)) + } + if event.scrollingDeltaX != 0 { + wheel(event.scrollingDeltaX > 0 ? PARDES_MOUSE_WHEEL_LEFT : PARDES_MOUSE_WHEEL_RIGHT, + at: at, mods: modifiers(event.modifierFlags)) + } } - 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) + /// Two fingers twisted on the trackpad are the search-step keys: clockwise + /// walks forward through the matches, counterclockwise back. It is a dial, + /// and n/N is what a dial over a list of hits means. libpardes owns the + /// quantizing, exactly as it does for scroll. + override func rotate(with event: NSEvent) { + trace("rotate: degrees=\(event.rotation) phase=\(event.phase.rawValue)") + // A gesture starting drops whatever the last one left banked, so the + // first degree of a new twist cannot inherit a nearly-complete notch. + if event.phase == .began { pardes_rotate(0) } + rotate(degrees: CGFloat(event.rotation)) } - private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? { + /// What the trackpad actually delivered, under PARDES_LOG — the same + /// variable the Zig side gates its logger on (src/macos.zig). + /// + /// This is not scaffolding left behind. Which events a trackpad produces is + /// decided by the hardware and by four different System Settings switches + /// (secondary click, three-finger drag, force click, "look up"), none of + /// which this process can read, and every one of which turns a gesture into + /// a different NSEvent or into none at all. When someone reports that + /// two-finger Look does nothing, this is the only thing that can answer + /// whether AppKit saw two fingers, one, or no click at all. + private func trace(_ message: @autoclosure () -> String) { + guard PardesView.tracing else { return } + FileHandle.standardError.write(Data(("pardes: " + message() + "\n").utf8)) + } + + private static let tracing = ProcessInfo.processInfo.environment["PARDES_LOG"] != nil + + private func cell(for event: NSEvent) -> GridPoint? { + cellAt(convert(event.locationInWindow, from: nil)) + } + + func cellAt(_ point: CGPoint) -> GridPoint? { // 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 @@ -429,10 +941,9 @@ final class PardesView: NSView { 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)) + return GridPoint(col: UInt16(col), row: UInt16(row)) } // MARK: - geometry @@ -448,6 +959,12 @@ final class PardesView: NSView { userInfo: nil)) } + override func resetCursorRects() { + // Every cell in this view is text, including the tags. An arrow over a + // grid you can sweep and click words in is the wrong affordance. + addCursorRect(bounds, cursor: .iBeam) + } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) // AppKit resizes a view many times over one drag and almost all of those @@ -475,4 +992,5 @@ final class PardesView: NSView { // 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. +// someone needs to type Japanese, and it needs the core to be able to render an +// underlined preedit run first. diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh index 6734f8d3..a54e76b8 100755 --- a/src/macos/build-app.sh +++ b/src/macos/build-app.sh @@ -1,7 +1,8 @@ #!/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. +# $1, the deployment target as $2 and the Zig optimize mode as $3 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 @@ -13,8 +14,28 @@ set -eu root=$(cd "$(dirname "$0")/../.." && pwd) out=${1:-"$root/zig-out"} +# Keep in step with macos_min_version in build.zig, which passes it in. The +# default is only for a by-hand run. +minver=${2:-13.0} +# Both swiftc invocations below take the same triple; the note above the app +# link is why -target is not optional for either of them. +target="$(uname -m)-apple-macos$minver" app="$out/pardes.app" lib="$out/lib/libpardes.a" +# The two halves of this program are compiled by two compilers, and before this +# only one of them was told anything: swiftc was hardcoded to -O while the Zig +# core followed -Doptimize, so the ordinary `zig build macos-app` produced an +# optimized shell around a DEBUG core. It does not read as "I built Debug", it +# reads as "the mac backend is slow" — measured on this machine, one frame at +# 190x56 cost 4.5 ms with a Debug core and 0.88 ms with a ReleaseFast one, and +# 4.1 ms of that 4.5 was pardes_frame alone. So the mode travels, both halves +# agree, and a slow bundle says why. +zigmode=${3:-Debug} +case $zigmode in +Debug) swiftmode=-Onone ;; +ReleaseSmall) swiftmode=-Osize ;; +*) swiftmode=-O ;; +esac [ -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; } @@ -22,6 +43,26 @@ command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command rm -rf "$app" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist" +# One version, three consumers: the plist's claim is set from the same string +# the link below enforces, so a bumped deployment target cannot leave a stale +# LSMinimumSystemVersion behind. +/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $minver" "$app/Contents/Info.plist" >/dev/null + +# The icon is generated rather than committed. The mark is drawn out of the +# same palette PardesView.swift renders cells with — #121212 body, the tag bar +# and the block cursor straight from ansi16 — so a colour that moves there +# moves here on the next build, instead of a binary blob sitting in the tree +# quietly disagreeing with the app it ships in. Info.plist's CFBundleIconFile +# names the pardes.icns this drops into Resources; without both halves the Dock +# falls back to the generic blank page. +# +# The trap covers the compiled generator; the generator clears its own scratch +# iconset. set -eu means a failure here takes the whole build down, which is +# the point: a bundle that ships a blank icon should not have built. +icontmp=$(mktemp -d) +trap 'rm -rf "$icontmp"' EXIT +swiftc -O -target "$target" -o "$icontmp/pardes-icon" "$root/src/macos/icon.swift" +"$icontmp/pardes-icon" "$app/Contents/Resources" # -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 @@ -35,11 +76,19 @@ cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist" # 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" \ +swiftc "$swiftmode" -target "$target" \ -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" +# An .app whose mtime never moves is an .app Launch Services keeps serving from +# its cache, icon and plist and all. +touch "$app" + +if [ "$zigmode" = Debug ]; then + echo "built $app (Debug core — rebuild with -Doptimize=ReleaseFast to use it)" +else + echo "built $app" +fi diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh new file mode 100755 index 00000000..7e0d2233 --- /dev/null +++ b/src/macos/build-e2e.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Link the offscreen end-to-end harness: the app's own Swift shell, plus +# test/macos_e2e.swift as the entry point, against the same libpardes.a. Run it +# through `zig build macos-e2e -Dplatform=macos`, or by hand with the install +# prefix as $1 and the deployment target as $2. +# +# A SECOND BINARY rather than a `--e2e` flag on the app, for two reasons. +# +# Test scaffolding does not ship inside the product. A flag would put the script +# interpreter, the /tmp world-builder and the golden differ into the thing a +# user launches, and would give the app a mode in which it rewrites files under +# /tmp and calls exit() — none of which anyone should be one argv typo away from. +# +# And src/macos/Sources/main.swift holds top-level code, which IS an entry +# point: a module cannot contain both top-level statements and a @main type, so +# the harness could not join that link even if the first reason went away. Every +# other Swift file the app builds from is compiled here, so this link is also +# what proves the shell still compiles as a library rather than as an app. +set -eu + +root=$(cd "$(dirname "$0")/../.." && pwd) +out=${1:-"$root/zig-out"} +# Keep in step with macos_min_version in build.zig, which passes it in. The +# default is only for a by-hand run. Same string as build-app.sh, and for the +# same reason: -target is what decides LC_BUILD_VERSION and turns on the +# availability diagnostics. +minver=${2:-13.0} +lib="$out/lib/libpardes.a" +bin="$out/bin/pardes-macos-e2e" + +[ -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; } + +mkdir -p "$out/bin" + +# -import-objc-header and -lc++ are build-app.sh's, unchanged, and have to stay +# that way: this link exists to exercise the app's link, so anything that +# differs here is something the harness cannot vouch for. +# +# -O for the same reason. A debug build of the CoreText pass and the effect +# drain settles on different timings than a user sees, and `stable` waits on +# exactly those timings. +swiftc -O -target "$(uname -m)-apple-macos$minver" \ + -import-objc-header "$root/src/macos/pardes.h" \ + -o "$bin" \ + "$root/src/macos/Sources/PardesView.swift" \ + "$root/src/macos/Sources/AppDelegate.swift" \ + "$root/test/macos_e2e.swift" \ + "$lib" -lc++ \ + -framework AppKit -framework CoreText -framework CoreGraphics + +echo "built $bin" diff --git a/src/macos/icon.swift b/src/macos/icon.swift new file mode 100644 index 00000000..b5de1838 --- /dev/null +++ b/src/macos/icon.swift @@ -0,0 +1,266 @@ +// Draws pardes.app's icon at build time and hands the result to iconutil. +// +// It is generated instead of committed because the mark IS the palette: the +// body is defaultBG, the tag bar and the block cursor are entries out of the +// same ansi16 table src/macos/Sources/PardesView.swift paints cells with. A +// checked-in .icns is a binary blob that stops matching the app the first time +// one of those colours moves, silently, with nothing in a diff to catch it. +// +// src/macos/build-app.sh compiles this file alone into a temporary binary and +// runs it with the bundle's Contents/Resources as argv[1]; Info.plist's +// CFBundleIconFile names the pardes.icns that comes out. Compiled alone is also +// what makes top-level code legal here: one file, one module, its own binary. +// +// Byte-identical output for byte-identical input is a requirement, not a +// nicety — an icns that churns on every build is a bundle that churns on every +// build, and Launch Services notices. Hence a pinned sRGB colour space, integer +// geometry, and nothing read from the clock or the environment. + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Every failure path lands here. A build that ships the generic blank-page +/// icon looks like an app nobody finished, so half-drawn is worse than absent +/// and the script has `set -e` waiting for the exit code. +func die(_ message: String) -> Never { + fputs("icon.swift: \(message)\n", stderr) + exit(1) +} + +struct RGB { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + + init(_ hex: UInt32) { + red = CGFloat((hex >> 16) & 0xFF) / 255 + green = CGFloat((hex >> 8) & 0xFF) / 255 + blue = CGFloat(hex & 0xFF) / 255 + } + + func components(_ alpha: CGFloat) -> [CGFloat] { [red, green, blue, alpha] } +} + +// Straight out of PardesView.swift. If those move these move, because the icon +// is a picture of the running program and a stale picture is worse than none: +// it looks deliberate. +let bodyTop = RGB(0x12_12_12) // defaultBG +let bodyBottom = RGB(0x0A_0A_0A) // defaultBG, shaded +let tagBar = RGB(0x34_65_A4) // ansi16[4], the muted blue +let text = RGB(0xCC_CC_CC) // defaultFG +let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow + +// Apple's icon grid rather than the whole square: the artwork is a rounded +// square floating in a transparent margin, 824 of 1024 with a 185.4 corner +// radius in the template — 80.47% of the canvas, and 22.37% of the SQUARE, not +// of the canvas, which would over-round it by a quarter. Filling the canvas +// edge to edge is the loudest tell that an app was not built on a Mac. +let squareFraction: CGFloat = 0.8047 +let cornerFraction: CGFloat = 0.2237 + +// The composition, as fractions of the body square. It has to survive being +// twelve pixels across, so it is four bands and nothing else: the full-width +// tag bar, a dim line for the pane that does not hold the keyboard, a gutter +// wide enough to read as the boundary between panes, then the focused pane's +// line with the block cursor parked at its end. Three shapes under the bar is +// the entire budget; a fourth is grey mush at 16 pixels. +let tagHeight: CGFloat = 0.165 +let sidePad: CGFloat = 0.145 +let lineHeight: CGFloat = 0.085 +let dimLineTop: CGFloat = 0.355 +let dimLineWidth: CGFloat = 0.505 +let dimLineAlpha: CGFloat = 0.52 +let liveLineTop: CGFloat = 0.645 +let liveLineWidth: CGFloat = 0.300 +let liveLineAlpha: CGFloat = 0.72 +let cursorLeft: CGFloat = 0.515 +// A cell, not a square: a block cursor covers a whole character box, so it is +// wider than the gap before it and well over twice the height of the ink it +// sits on. The exact numbers are also the ones that survive rounding — 0.205 +// of a twelve-pixel body spans 2.46 pixels, which lands on two rows wherever +// the top edge falls, whereas anything near 0.155 collapses to one row for +// half the possible alignments and the block turns into a dash. +let cursorWidth: CGFloat = 0.130 +let cursorHeight: CGFloat = 0.205 + +/// sRGB in the bitmap and sRGB in every colour put into it. `setFillColor(red: +/// green:blue:alpha:)` speaks DeviceRGB, which is a colour match on the way in, +/// and #121212 would stop being #121212. +func sRGB() -> CGColorSpace { + guard let space = CGColorSpace(name: CGColorSpace.sRGB) else { die("sRGB colour space unavailable") } + return space +} + +func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor { + guard let color = CGColor(colorSpace: sRGB(), components: rgb.components(alpha)) else { + die("CGColor from sRGB components failed") + } + return color +} + +/// Whole device pixels, or the 16pt render turns each one-pixel bar into two +/// rows of half-lit grey and the whole mark goes soft. Rounding the edges +/// rather than the size is what keeps the gaps between bands even. +func snap(_ rect: CGRect) -> CGRect { + let left = rect.minX.rounded() + let top = rect.minY.rounded() + return CGRect( + x: left, y: top, + width: max(1, rect.maxX.rounded() - left), + height: max(1, rect.maxY.rounded() - top)) +} + +func renderIcon(pixels: Int) -> CGImage { + guard + let ctx = CGContext( + data: nil, width: pixels, height: pixels, + bitsPerComponent: 8, bytesPerRow: 0, space: sRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) + else { die("CGContext \(pixels)x\(pixels) failed") } + + // Top-left origin, so the constants above read in the order the picture + // does. The scale stays ±1, which is what lets snap() round in user space. + ctx.translateBy(x: 0, y: CGFloat(pixels)) + ctx.scaleBy(x: 1, y: -1) + + // Round the MARGIN and derive the square from it. Rounding the square + // instead leaves an odd remainder to split, and at 16 pixels the artwork + // lands a pixel off centre. At 1024 this is Apple's 100/824/100 exactly. + let canvas = CGFloat(pixels) + let inset = (canvas * (1 - squareFraction) / 2).rounded() + let side = canvas - 2 * inset + let body = CGRect(x: inset, y: inset, width: side, height: side) + let corner = side * cornerFraction + + ctx.saveGState() + ctx.addPath(CGPath(roundedRect: body, cornerWidth: corner, cornerHeight: corner, transform: nil)) + ctx.clip() + + // The only gradient in the icon, and it earns its place: a flat near-black + // square reads as a hole punched in the Dock rather than as an object. + let stops = bodyTop.components(1) + bodyBottom.components(1) + let locations: [CGFloat] = [0, 1] + guard + let gradient = CGGradient( + colorSpace: sRGB(), colorComponents: stops, locations: locations, count: 2) + else { die("CGGradient failed") } + ctx.drawLinearGradient( + gradient, + start: CGPoint(x: body.midX, y: body.minY), + end: CGPoint(x: body.midX, y: body.maxY), + options: []) + + // Full bleed, and still inside the clip so its top corners round with the + // body. src/pardes.zig fills row 0 across the whole width the same way; + // that strip is the silhouette of an acme screen and it is the one thing + // that has to survive being two pixels tall. + ctx.setFillColor(cgColor(tagBar)) + ctx.fill( + CGRect( + x: body.minX, y: body.minY, + width: side, height: max(1, (side * tagHeight).rounded()))) + ctx.restoreGState() + + // Two panes. The gap between the lines is deliberately the widest space in + // the icon: it is the pane boundary, and proximity is the only way to say + // so without spending the fourth shape on a divider. + let leftEdge = body.minX + side * sidePad + ctx.setFillColor(cgColor(text, alpha: dimLineAlpha)) + ctx.fill( + snap( + CGRect( + x: leftEdge, y: body.minY + side * dimLineTop, + width: side * dimLineWidth, height: side * lineHeight))) + + ctx.setFillColor(cgColor(text, alpha: liveLineAlpha)) + ctx.fill( + snap( + CGRect( + x: leftEdge, y: body.minY + side * liveLineTop, + width: side * liveLineWidth, height: side * lineHeight))) + + // Centred on the line it follows and taller than it, the way a block cursor + // covers a whole cell rather than the ink in it. Full-strength yellow + // against the blue is what tells you at a glance which pane has focus. + let cursorTop = liveLineTop + (lineHeight - cursorHeight) / 2 + ctx.setFillColor(cgColor(cursor)) + ctx.fill( + snap( + CGRect( + x: body.minX + side * cursorLeft, y: body.minY + side * cursorTop, + width: side * cursorWidth, height: side * cursorHeight))) + + guard let image = ctx.makeImage() else { die("CGContext.makeImage failed at \(pixels)") } + return image +} + +func writePNG(_ image: CGImage, to url: URL) { + guard + let sink = CGImageDestinationCreateWithURL( + url as CFURL, UTType.png.identifier as CFString, 1, nil) + else { die("cannot open \(url.path) for writing") } + CGImageDestinationAddImage(sink, image, nil) + guard CGImageDestinationFinalize(sink) else { die("encoding \(url.lastPathComponent) failed") } +} + +// The ten names iconutil demands, spelled out rather than derived: the set is +// fixed by the tool, and a loop that generated them would be a loop to read +// before believing the list. 32, 256 and 512 appear twice under two names and +// are simply drawn twice — a third of a megapixel, against the clarity of not +// caching anything in a build tool. +let variants: [(name: String, pixels: Int)] = [ + ("icon_16x16.png", 16), + ("[email protected]", 32), + ("icon_32x32.png", 32), + ("[email protected]", 64), + ("icon_128x128.png", 128), + ("[email protected]", 256), + ("icon_256x256.png", 256), + ("[email protected]", 512), + ("icon_512x512.png", 512), + ("[email protected]", 1024), +] + +let arguments = CommandLine.arguments +guard arguments.count == 2 else { + die("usage: \(URL(fileURLWithPath: arguments.first ?? "icon").lastPathComponent) <output-directory>") +} + +let files = FileManager.default +let outputDir = URL(fileURLWithPath: arguments[1], isDirectory: true) +let output = outputDir.appendingPathComponent("pardes.icns") + +// A fixed scratch path, cleared before use rather than a unique one: a run that +// died half way leaves a partial iconset behind, and iconutil would happily +// fold the stale sizes into the next icns without a word. +let scratch = files.temporaryDirectory.appendingPathComponent("pardes-icon", isDirectory: true) +let iconset = scratch.appendingPathComponent("pardes.iconset", isDirectory: true) + +try? files.removeItem(at: scratch) +do { + try files.createDirectory(at: iconset, withIntermediateDirectories: true) + try files.createDirectory(at: outputDir, withIntermediateDirectories: true) +} catch { + die("cannot create \(iconset.path): \(error.localizedDescription)") +} + +for variant in variants { + writePNG(renderIcon(pixels: variant.pixels), to: iconset.appendingPathComponent(variant.name)) +} + +let iconutil = Process() +iconutil.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +iconutil.arguments = ["-c", "icns", "-o", output.path, iconset.path] +do { + try iconutil.run() +} catch { + die("cannot run /usr/bin/iconutil: \(error.localizedDescription)") +} +iconutil.waitUntilExit() +guard iconutil.terminationStatus == 0 else { + die("iconutil exited \(iconutil.terminationStatus) over \(iconset.path)") +} + +try? files.removeItem(at: scratch) diff --git a/src/macos/pardes.h b/src/macos/pardes.h index e0d6fa32..62ddbae5 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -118,6 +118,15 @@ typedef enum { PARDES_MOUSE_DRAG = 3, } pardes_mouse_kind_e; +// What the core just did, for a shell that can answer with something the hand +// feels. Taken with pardes_take_haptic once per pump; the two acme verbs are +// distinguished because they deserve distinct taps. +typedef enum { + PARDES_HAPTIC_NONE = 0, + PARDES_HAPTIC_EXEC = 1, + PARDES_HAPTIC_LOOK = 2, +} pardes_haptic_e; + // ---------------------------------------------------------------- runtime // What the host lends the core. Two callbacks, because everything else the @@ -149,8 +158,14 @@ 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. +// every input function and on every wakeup. +// +// The return value is whether this tick did any IO — bytes arrived from a pty, +// or an effect was performed. It is NOT a repaint signal, and a host that uses +// it as one shows a stale screen: moving the cursor, extending a selection, +// changing mode and scrolling all mutate the grid while queueing nothing and +// performing nothing, so they tick false. Mark the view dirty after any call +// into the core and use this only to decide whether there was work. bool pardes_tick(void); // The core asked to exit (the Exit builtin, or the last pane closing). @@ -169,14 +184,30 @@ 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); +// Trackpad/precision wheel distance in CELLS, sign following the grid +// (positive scrolls down and right). The core has no fractional scroll — it +// moves a row or a column at a time — so libpardes accumulates here and emits +// whole 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, and +// so the quantizer is unit-tested on a machine with no trackpad. A discrete +// wheel notch should go through pardes_mouse instead. +void pardes_scroll(float delta_rows, float delta_cols, uint16_t col, + uint16_t row); + +// A two-finger trackpad rotation, in degrees since the last call, positive +// counterclockwise (AppKit's sign, unchanged). The core has no rotation: this +// is spent as the search-step keys, clockwise `n` and counterclockwise `N`, a +// notch at a time with the remainder kept — the same accumulate-and-spend +// shape as pardes_scroll, and in Zig for the same reason. Feed it the raw +// per-event delta; pass 0 at gesture start to drop a stale remainder. +void pardes_rotate(float degrees); + +// Run one builtin command line, exactly as executing the same text in a tag +// would. This is the core's own `command` event, which is how a nested pardes +// talks to its host; here it is what a menu item is made of, and what opens +// the file named on argv or dropped on the Dock icon (`Look <path>`). +void pardes_command(const char *text, size_t len); // `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. @@ -197,6 +228,29 @@ int32_t pardes_cursor_x(void); int32_t pardes_cursor_y(void); bool pardes_cursor_bar(void); +// The Look or Exec the core performed since this was last asked, and clears +// it. Call it once per pump, after pardes_tick — the input functions run the +// dispatch synchronously, so a gesture's pulse is already waiting by the time +// its tick returns. PARDES_HAPTIC_NONE means nothing to feel. +pardes_haptic_e pardes_take_haptic(void); + +// The font file the `Font` builtin asked for since this was last called, and +// clears it; NULL when nothing was asked. Ask once per pump, beside the haptic +// above. The string is a NUL-terminated absolute path owned by libpardes and +// valid until the next call. +// +// A path rather than a family name: the core found the file by walking the +// font directories itself, so both shells agree on which faces exist and +// neither has to ask its platform to resolve a name it might resolve +// differently. A `.ttc` collection names its first face, which is the cut the +// file is named after. +// +// The host loads it, re-measures its cell, and reports the new grid through +// pardes_resize. A file the host cannot load is one to ignore: keep wearing +// the face that works, because a terminal that cannot draw has no way back +// out of it. +const char *pardes_font_take(void); + #ifdef __cplusplus } #endif diff --git a/src/main.zig b/src/main.zig index 607b0136..96aedc0a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -233,13 +233,11 @@ fn parseCtrlKey(raw: []const u8) ?u21 { // so their inline tests need naming here to exist at all. gui.zig only // compiles when it IS the shell (it @cImports SDL), hence the comptime gate; // under -Dplatform=tty this block analyses to nothing. Naming a file gets THAT -// file's tests and no further: fonts.zig is imported by both gui.zig and -// builtins.zig and still needs its own line here. +// file's tests and no further: fonts.zig is imported by gui.zig, builtins.zig +// and the macOS host, and still needs its own line here. test { _ = @import("user_config.zig"); _ = @import("allocators.zig"); - if (comptime pardes.platform == .gui) { - _ = @import("gui/gui.zig"); - _ = @import("gui/fonts.zig"); - } + if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); + if (comptime pardes.font_picker) _ = @import("fonts.zig"); } diff --git a/src/nested.zig b/src/nested.zig index 20c42dd0..a8fd021d 100644 --- a/src/nested.zig +++ b/src/nested.zig @@ -17,20 +17,69 @@ //! socket sits at a path anyone can derive from a pid — `Exec …` arriving here //! is not something this protocol is allowed to say. //! -//! Linux only. ponytail: darwin has no /proc, no SOCK_CLOEXEC and no accept4, -//! and its `sockaddr.un.path` is 104 bytes rather than the 108 every buffer -//! and unguarded memcpy below assumes. None of that is testable from here, so -//! detection is simply off: a pardes inside a pardes on macOS opens a second -//! session the way it always did. +//! Linux and darwin. The two differ in every primitive this needs and in none +//! of the design: /proc against libproc for the ancestor walk, SOCK_CLOEXEC +//! and accept4 against a plain socket plus an fcntl, and a `sun_path` of 108 +//! bytes against one of 104 — which is why no buffer below spells a number, +//! they are all sized from the field itself. Anywhere else the walk returns +//! null and a pardes inside a pardes opens a second session, as before. +//! +//! macOS also has a third executable in the family: the app bundle. Its binary +//! is the same build as `bin/pardes` installed a second time, at a path that +//! shares nothing below the install prefix, so identity is compared at that +//! prefix — see samePardesExecutable. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; -const linux = std.os.linux; // statx; referenced only on linux // std.c has getenv but neither setter; the tests below need both extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; +const darwin = switch (builtin.os.tag) { + .macos, .ios, .tvos, .watchos, .visionos => true, + else => false, +}; + +/// This module is only as portable as its two ingredients: a way to name the +/// executable and parent of an arbitrary pid, and unix sockets. +const supported = builtin.os.tag == .linux or darwin; + +/// `sun_path` is 108 bytes on linux and 104 on darwin, and it is the hard +/// limit on this whole feature: a path that does not fit is not a socket +/// address, it is a truncated one pointing somewhere else. Taken from the +/// struct so that the buffers, the fit checks and the memcpy below cannot +/// disagree with the kernel or with each other. +const sun_path_len = @typeInfo(@FieldType(libc.sockaddr.un, "path")).array.len; + +/// libproc, darwin's answer to /proc. `proc_pidpath` is readlink of +/// `/proc/<pid>/exe`; `PROC_PIDTBSDINFO` carries the parent pid that linux +/// spells `PPid:`. Both are same-uid readable, which is the only permission +/// an ancestor walk through one's own processes needs. +const PROC_PIDTBSDINFO: c_int = 3; +const proc_bsdinfo = extern struct { + flags: u32, + status: u32, + xstatus: u32, + pid: u32, + ppid: u32, + /// uids, gids, comm, name, the tty and the start time: filled by the + /// kernel and unread here, but the call fails unless the buffer is the + /// whole 136-byte record. + rest: [116]u8, +}; +extern "c" fn proc_pidpath(pid: c_int, buffer: *anyopaque, buffersize: u32) c_int; +extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; + +/// Linux opens sockets CLOEXEC in one call; darwin has to set it afterwards. +/// The gap is a race only against a fork on another thread, and both callers +/// are past that: `listen` runs before the first pane exists, and `acceptLine` +/// runs on a thread of its own long after spawning has settled. +fn setCloexec(fd: c_int) void { + const FD_CLOEXEC: c_int = 1; + _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); +} + /// The longest command line this protocol carries or accepts. `Look ` plus a /// PATH_MAX path fits with room over; anything longer cannot have come from /// the client and is dropped rather than truncated into a different command. @@ -41,7 +90,7 @@ pub const max_line = 4200; /// is per-user for the same reason a home directory is. Asked by the client /// (to derive the path), by the listener (to create and vet it) and by the /// sweeper (to scan it), so it is written once. -fn socketDir(buf: *[108:0]u8) ?[:0]const u8 { +fn socketDir(buf: *[sun_path_len:0]u8) ?[:0]const u8 { if (libc.getenv("XDG_RUNTIME_DIR")) |x| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(x)}, 0) catch null; const home = libc.getenv("HOME") orelse return null; @@ -52,8 +101,8 @@ fn socketDir(buf: *[108:0]u8) ?[:0]const u8 { /// two pardes never collide and a nested child derives the exact path from the /// ancestor pid its tree walk found. The buffer is sun_path-sized: a longer /// path is not a socket address at all. -pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 { - var dir_buf: [108:0]u8 = undefined; +pub fn socketPath(buf: *[sun_path_len]u8, pid: libc.pid_t) ?[:0]const u8 { + var dir_buf: [sun_path_len:0]u8 = undefined; const dir = socketDir(&dir_buf) orelse return null; // unsigned: {d} prints a leading '+' for a positive SIGNED int return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null; @@ -66,34 +115,69 @@ fn stripDeleted(link: []const u8) []const u8 { return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link; } -/// The tty and SDL builds are sibling frontends of the same program. Their -/// installed names differ only by `-gui` (and, for cross builds, share the -/// same `-os-arch` tail), so either one must recognise the other as an outer -/// pardes. Requiring the same directory retains the executable-identity check: -/// an unrelated ancestor merely named `pardes` is not enough. +/// The install prefix a program directory belongs to. `bin/pardes` and +/// `pardes.app/Contents/MacOS/pardes` are one build installed twice and share +/// no directory at all, so comparing dirnames says they are strangers; both +/// reduce to the prefix, and so does everything else — a directory that is +/// neither wrapper is its own prefix, which leaves the same-directory rule +/// below exactly as strict as it was. +fn installPrefix(dir: []const u8) []const u8 { + const macos_dir = "/Contents/MacOS"; + if (std.mem.endsWith(u8, dir, macos_dir)) { + const app = dir[0 .. dir.len - macos_dir.len]; + if (std.mem.endsWith(u8, app, ".app")) return std.fs.path.dirname(app) orelse app; + } + const bin = "/bin"; + if (std.mem.endsWith(u8, dir, bin)) return dir[0 .. dir.len - bin.len]; + return dir; +} + +/// The tty, SDL and macOS builds are sibling frontends of the same program. +/// Their installed names differ only by `-gui` (and, for cross builds, share +/// the same `-os-arch` tail), or not at all when one of them is the app bundle +/// — so any of them must recognise any other as an outer pardes. Requiring the +/// same install prefix retains the executable-identity check: an unrelated +/// ancestor merely named `pardes` is not enough. fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { const a = stripDeleted(a_raw); const b = stripDeleted(b_raw); if (std.mem.eql(u8, a, b)) return true; - const a_dir = std.fs.path.dirname(a) orelse return false; - const b_dir = std.fs.path.dirname(b) orelse return false; + const a_dir = installPrefix(std.fs.path.dirname(a) orelse return false); + const b_dir = installPrefix(std.fs.path.dirname(b) orelse return false); if (!std.mem.eql(u8, a_dir, b_dir)) return false; - const a_name = std.fs.path.basename(a); - const b_name = std.fs.path.basename(b); - const gui = "pardes-gui"; - const tty = "pardes"; - const a_gui = std.mem.startsWith(u8, a_name, gui); - const b_gui = std.mem.startsWith(u8, b_name, gui); - if (a_gui == b_gui) return false; - const gui_name = if (a_gui) a_name else b_name; - const tty_name = if (a_gui) b_name else a_name; - if (!std.mem.startsWith(u8, tty_name, tty)) return false; - const gui_tail = gui_name[gui.len..]; - const tty_tail = tty_name[tty.len..]; - if ((gui_tail.len != 0 and gui_tail[0] != '-') or - (tty_tail.len != 0 and tty_tail[0] != '-')) return false; - return std.mem.eql(u8, gui_tail, tty_tail); + return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); +} + +/// What is left of a family name after the frontend part: `` for `pardes` and +/// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null +/// when the name is not in the family at all — `not-pardes` and `pardesfoo` +/// are other programs. +fn familyTail(name: []const u8) ?[]const u8 { + const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) + name["pardes-gui".len..] + else if (std.mem.startsWith(u8, name, "pardes")) + name["pardes".len..] + else + return null; + // `pardesfoo` shares a prefix and nothing else. A tail is a tail or empty. + if (rest.len != 0 and rest[0] != '-') return null; + return rest; +} + +/// Two family names for the same build, given that they already share an +/// install prefix. The tails have to agree — a linux binary and an x86_64 one +/// in the same directory are two builds — unless one of them has no tail at +/// all, which is the untagged name the default build and, unavoidably, the app +/// bundle both produce: CFBundleExecutable is a fixed string, so the bundled +/// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name +/// records what it was. Loosening it that far is safe because the prefix +/// already had to match, and a foreign-arch ancestor cannot be running here. +fn sameFamily(a: []const u8, b: []const u8) bool { + if (std.mem.eql(u8, a, b)) return true; + const a_tail = familyTail(a) orelse return false; + const b_tail = familyTail(b) orelse return false; + return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); } /// The `PPid:` field of a /proc/<pid>/status blob. Deliberately NOT field 4 of @@ -120,34 +204,70 @@ fn sweepPid(name: []const u8) ?libc.pid_t { return std.fmt.parseInt(libc.pid_t, digits, 10) catch null; } +/// Name the executable behind a pid, the way this OS spells it. +fn exeOf(pid: libc.pid_t, buf: *[4096]u8) ?[]const u8 { + switch (builtin.os.tag) { + .linux => { + var name: [64:0]u8 = undefined; + const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; + const n = libc.readlink(link, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + else => { + if (comptime !darwin) return null; + // Documented to want a PROC_PIDPATHINFO_MAXSIZE buffer, which is + // exactly this one, and to return the length it wrote. + const n = proc_pidpath(pid, buf, @intCast(buf.len)); + if (n <= 0) return null; + return buf[0..@intCast(n)]; + }, + } +} + +/// ...and its parent. +fn parentOf(pid: libc.pid_t) ?libc.pid_t { + switch (builtin.os.tag) { + .linux => { + var name: [64:0]u8 = undefined; + var buf: [4096]u8 = undefined; + const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; + const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + const got = libc.read(fd, &buf, buf.len); + _ = libc.close(fd); + if (got <= 0) return null; + return parsePPid(buf[0..@intCast(got)]); + }, + else => { + if (comptime !darwin) return null; + var info: proc_bsdinfo = undefined; + const n = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, @sizeOf(proc_bsdinfo)); + // A short answer means the record this was compiled against is not + // the one the kernel filled, and `ppid` is then some other field. + if (n < @as(c_int, @sizeOf(proc_bsdinfo))) return null; + return @intCast(info.ppid); + }, + } +} + /// The pid of the nearest ancestor running a pardes executable, or null. -/// Identity is `readlink("/proc/<pid>/exe")` against our own; the tty `pardes` -/// and SDL `pardes-gui` siblings also match when they live in the same -/// directory. A name alone would call every unrelated `pardes` ancestor an -/// outer instance. The hop cap is not for /proc, which cannot loop, but because -/// the walk is driven by numbers read out of files and should not be able to +/// Identity is that ancestor's executable path against our own; the tty, SDL +/// and app-bundle siblings also match when they were installed together. A +/// name alone would call every unrelated `pardes` ancestor an outer instance. +/// The hop cap is not for the process tree, which cannot loop, but because the +/// walk is driven by numbers read out of the kernel and should not be able to /// spin on a surprising one. pub fn outer() ?libc.pid_t { - if (comptime builtin.os.tag != .linux) return null; + if (comptime !supported) return null; var self_buf: [4096]u8 = undefined; - const self_n = libc.readlink("/proc/self/exe", &self_buf, self_buf.len); - if (self_n <= 0) return null; - const self_exe = stripDeleted(self_buf[0..@intCast(self_n)]); + const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null; var pid = libc.getppid(); var hops: usize = 0; while (pid > 1 and hops < 64) : (hops += 1) { - var name: [64:0]u8 = undefined; var buf: [4096]u8 = undefined; - const exe = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; - const n = libc.readlink(exe, &buf, buf.len); - if (n > 0 and samePardesExecutable(buf[0..@intCast(n)], self_exe)) return pid; - const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null; - const fd = libc.open(status, .{ .ACCMODE = .RDONLY }); - if (fd < 0) return null; - const got = libc.read(fd, &buf, buf.len); - _ = libc.close(fd); - if (got <= 0) return null; - pid = parsePPid(buf[0..@intCast(got)]) orelse return null; + if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid; + pid = parentOf(pid) orelse return null; } return null; } @@ -159,7 +279,7 @@ pub fn outer() ?libc.pid_t { /// caller its own launch. Writes and returns: the answer is a pane appearing /// on someone else's screen, and there is nothing to wait for. pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { - if (comptime builtin.os.tag != .linux) return false; + if (comptime !supported) return false; // The protocol is one line, so a path with a line break IN it says // something else entirely: `we\nird.txt` arrived as `Look .../we` and the // outer instance opened a different file that happened to exist. \r goes @@ -172,12 +292,15 @@ pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool { else std.fmt.bufPrint(&cmd_buf, "Look {s}\n", .{path})) catch return false; - var path_buf: [108]u8 = undefined; + // sun_path-sized by construction, so `sock` cannot be longer than the + // field it is about to be copied into — socketPath returns null instead. + var path_buf: [sun_path_len]u8 = undefined; const sock = socketPath(&path_buf, pid) orelse return false; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return false; + setCloexec(fd); defer _ = libc.close(fd); if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; var off: usize = 0; @@ -202,7 +325,7 @@ fn ensureSocketDir(dir: [:0]const u8) bool { // without ~/.local/state would otherwise switch the feature off in // silence. Under $XDG_RUNTIME_DIR every prefix already exists and simply // EEXISTs, which is the ordinary case for the leaf too. - var partial: [108:0]u8 = undefined; + var partial: [sun_path_len:0]u8 = undefined; @memcpy(partial[0 .. dir.len + 1], dir[0 .. dir.len + 1]); for (1..dir.len) |i| { if (dir[i] != '/') continue; @@ -211,13 +334,16 @@ fn ensureSocketDir(dir: [:0]const u8) bool { partial[i] = '/'; } _ = libc.mkdir(dir, 0o700); - var stx: linux.Statx = undefined; - const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true }; - // NOFOLLOW: a symlink where the directory should be is exactly the plant - if (libc.statx(linux.AT.FDCWD, dir, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return false; - if (!linux.S.ISDIR(stx.mode)) return false; - if (stx.uid != libc.getuid()) return false; - return stx.mode & 0o077 == 0; + // fstatat rather than statx: the same three answers, on both platforms, + // and not following the symlink is the point — one where the directory + // should be is exactly the plant this guards against. + var st: libc.Stat = undefined; + if (libc.fstatat(libc.AT.FDCWD, dir, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return false; + const IFMT: u32 = 0o170000; + const IFDIR: u32 = 0o040000; + if (@as(u32, st.mode) & IFMT != IFDIR) return false; + if (st.uid != libc.getuid()) return false; + return st.mode & 0o077 == 0; } /// Unlink the socket files of pardes processes that are gone. A pardes killed @@ -235,7 +361,7 @@ fn sweep(dir: [:0]const u8) void { // 0 = alive; EPERM = alive and someone else's. Only ESRCH is a corpse. const rc = libc.kill(pid, @enumFromInt(0)); if (rc == 0 or libc.errno(rc) != .SRCH) continue; - var pbuf: [108]u8 = undefined; + var pbuf: [sun_path_len]u8 = undefined; _ = libc.unlink(socketPath(&pbuf, pid) orelse continue); } } @@ -251,18 +377,20 @@ fn sweep(dir: [:0]const u8) void { /// holding this one would keep the socket bound long after we exit — the same /// shape as the inherited lock fd that once held a flock forever. pub fn listen() c_int { - if (comptime builtin.os.tag != .linux) return -1; - var dir_buf: [108:0]u8 = undefined; + if (comptime !supported) return -1; + var dir_buf: [sun_path_len:0]u8 = undefined; const dir = socketDir(&dir_buf) orelse return -1; if (!ensureSocketDir(dir)) return -1; sweep(dir); - var path_buf: [108]u8 = undefined; + // Fits by construction: socketPath writes into a sun_path-sized buffer and + // returns null rather than a truncated address. + var path_buf: [sun_path_len]u8 = undefined; const path = socketPath(&path_buf, libc.getpid()) orelse return -1; var addr: libc.sockaddr.un = .{ .path = @splat(0) }; - if (path.len + 1 > addr.path.len) return -1; @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); - const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); if (fd < 0) return -1; + setCloexec(fd); _ = libc.unlink(path); // pid reuse: a dead pardes' file would EADDRINUSE forever if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { _ = libc.close(fd); @@ -281,11 +409,11 @@ pub fn listen() c_int { /// Close the listener and take its file away. Guarded on the fd rather than on /// the path, so a bind that FAILED cannot unlink a path this process never /// created; anything else is a no-op, which is what --nested and every -/// non-linux build hand it. +/// unsupported build hand it. pub fn unlisten(fd: c_int) void { if (fd < 0) return; _ = libc.close(fd); - var path_buf: [108]u8 = undefined; + var path_buf: [sun_path_len]u8 = undefined; if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path); } @@ -297,9 +425,9 @@ pub fn unlisten(fd: c_int) void { /// nothing. Every accepted connection is CLOEXEC for the reason the listener /// is. pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { - if (comptime builtin.os.tag != .linux) return null; + if (comptime !supported) return null; while (true) { - const conn = libc.accept4(fd, null, null, libc.SOCK.CLOEXEC); + const conn = libc.accept(fd, null, null); if (conn < 0) { switch (libc.errno(conn)) { .INTR => continue, @@ -315,6 +443,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { } } defer _ = libc.close(conn); + setCloexec(conn); // A peer that connects and says nothing must not hold the listener: // this is a serial accept loop, and one silent connection used to // block every later launch until it let go. The client writes its one @@ -342,7 +471,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 { } test "socket path: XDG first, then a private dir under HOME, never /tmp" { - var buf: [108]u8 = undefined; + var buf: [sun_path_len]u8 = undefined; // The environment is process-wide and every test in this binary shares it. // The last case below reaches the "no directory at all" branch by blanking // both variables, and without this every later test ran without a HOME. @@ -363,9 +492,11 @@ test "socket path: XDG first, then a private dir under HOME, never /tmp" { _ = unsetenv("XDG_RUNTIME_DIR"); _ = setenv("HOME", "/home/who", 1); try std.testing.expectEqualStrings("/home/who/.local/state/pardes/pardes-4242.sock", socketPath(&buf, 4242).?); - // sun_path is 108 bytes including the NUL, so a directory that long has no - // socket address at all — say so instead of binding a truncated one - _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** 100), 1); + // sun_path holds the NUL, so a directory that fills it has no socket + // address at all — say so instead of binding a truncated one. Sized from + // the field: the limit is 108 on linux and 104 on darwin, and a literal + // here would test nothing on whichever platform it was not written for. + _ = setenv("XDG_RUNTIME_DIR", "/" ++ ("x" ** (sun_path_len - 8)), 1); try std.testing.expect(socketPath(&buf, 4242) == null); _ = unsetenv("XDG_RUNTIME_DIR"); _ = unsetenv("HOME"); @@ -407,6 +538,137 @@ test "tty and GUI sibling executables recognise each other" { )); } +test "the app bundle is the same build as the binary installed beside it" { + // What `pardes foo.zig` typed into the bundle's own shell has to resolve: + // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., + // and nothing below zig-out is shared. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes", + )); + // ...and the SDL sibling, which reaches it by the name rule instead. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes-gui", + )); + // The case this machine actually produces: `zig build` installs the tty + // binary under its os-arch tail, and build-app.sh copies the same build + // into a bundle where it can only be called `pardes`. + try std.testing.expect(samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes-macos-aarch64", + )); + // A different install is still a different program, however alike the + // paths look — this is the whole point of comparing anything at all. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/pardes.app/Contents/MacOS/pardes", + "/opt/zig-out/bin/pardes", + )); + // The wrapper is only transparent when it IS the wrapper: `Contents/MacOS` + // under something that is not a bundle keeps its own directory. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/pardes/Contents/MacOS/pardes", + "/work/zig-out/bin/pardes", + )); + // Nothing here may loosen the rule for two unrelated programs that merely + // sit in a bin and a bundle of the same tree. + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/other.app/Contents/MacOS/other", + "/work/zig-out/bin/pardes", + )); +} + +test "the ancestor walk reads this process's own parent" { + // The one thing a hand-written `struct proc_bsdinfo` gets wrong silently: + // a field ordering that puts something else where ppid should be still + // returns a plausible number. getppid knows the answer, so compare. + // + // Also the only check that libproc answers us at all — every caller of + // outer() treats a failure as "no outer instance", which is exactly what a + // permission problem would look like. + if (comptime !supported) return error.SkipZigTest; + try std.testing.expectEqual(libc.getppid(), parentOf(libc.getpid()).?); + // ...and that the walk terminates rather than spinning on pid 1's parent. + try std.testing.expect(parentOf(1) == null or parentOf(1).? <= 1); + + var buf: [4096]u8 = undefined; + const exe = exeOf(libc.getpid(), &buf).?; + try std.testing.expect(exe.len > 0); + try std.testing.expect(exe[0] == '/'); + // The test binary is not a pardes, so the walk must come back empty rather + // than matching some ancestor by accident. + try std.testing.expect(outer() == null); +} + +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; +extern "c" fn rmdir(path: [*:0]const u8) c_int; + +test "a Look line survives the socket round trip" { + // Everything the protocol actually does, against a real kernel: bind, + // chmod, connect, write, accept, read, and the one-verb filter. The pure + // functions above cannot see any of it, and every primitive here is + // spelled differently on the two platforms this now supports. + if (comptime !supported) return error.SkipZigTest; + + // A private directory of our own. Not the developer's real state dir: this + // binds a socket named after a pid that is the TEST's, and sweep() unlinks + // what it finds beside it. + var tmpl: [64:0]u8 = undefined; + _ = std.fmt.bufPrintSentinel(&tmpl, "/tmp/pardes-nested-XXXXXX", .{}, 0) catch unreachable; + if (mkdtemp(&tmpl) == null) return error.SkipZigTest; + const dir = std.mem.sliceTo(&tmpl, 0); + defer _ = rmdir(tmpl[0..dir.len :0]); + + var xdg_buf: [4096:0]u8 = undefined; + const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null; + defer { + if (xdg0) |v| { + _ = setenv("XDG_RUNTIME_DIR", v, 1); + } else _ = unsetenv("XDG_RUNTIME_DIR"); + } + _ = setenv("XDG_RUNTIME_DIR", tmpl[0..dir.len :0], 1); + + const fd = listen(); + try std.testing.expect(fd >= 0); + defer unlisten(fd); + + // Sent to our own pid, which is the pid listen() named the socket after. + // The client closes as it returns, and the line is already queued, so the + // single-threaded accept below finds a complete connection waiting — no + // thread and no timeout needed to prove the protocol. + try std.testing.expect(sendLook(libc.getpid(), "/etc/hosts", 42)); + var buf: [max_line]u8 = undefined; + try std.testing.expectEqualStrings("Look /etc/hosts:42", acceptLine(fd, &buf).?); + + // ...and without a line number, which is the directory and image case. + try std.testing.expect(sendLook(libc.getpid(), "/etc", 0)); + try std.testing.expectEqualStrings("Look /etc", acceptLine(fd, &buf).?); + + // The socket takes one verb. Anything else is dropped rather than run, so + // the next Look is what comes back — proving the filter skipped it without + // dropping the connection after it. + try std.testing.expect(writeLine(libc.getpid(), "Exec rm -rf /\n")); + try std.testing.expect(sendLook(libc.getpid(), "/etc/passwd", 0)); + try std.testing.expectEqualStrings("Look /etc/passwd", acceptLine(fd, &buf).?); + + // A path that cannot be one line is not escaped, it is refused. + try std.testing.expect(!sendLook(libc.getpid(), "/etc/ho\nsts", 0)); +} + +/// sendLook with the framing bypassed, so a test can put something on the wire +/// that the client would never send. +fn writeLine(pid: libc.pid_t, line: []const u8) bool { + var path_buf: [sun_path_len]u8 = undefined; + const sock = socketPath(&path_buf, pid) orelse return false; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. sock.len + 1], sock[0 .. sock.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return false; + defer _ = libc.close(fd); + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return false; + return libc.write(fd, line.ptr, line.len) == @as(isize, @intCast(line.len)); +} + test "the sweep only recognises its own socket names" { try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?); try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?); diff --git a/src/output_pane.zig b/src/output_pane.zig index 8c93709c..593af3d3 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -27,7 +27,7 @@ const config = @import("config.zig"); const lsp = @import("lsp/lsp.zig"); /// the installed fonts, for openFonts. GUI only, behind the same comptime /// branch builtins.zig imports it through — see the note there. -const fonts = if (pardes.platform == .gui) @import("gui/fonts.zig") else struct {}; +const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {}; /// What opened this buffer — THE field, and the only input to `traits`. /// @@ -385,12 +385,13 @@ pub fn openThemes(p: *Pardes, id: usize) !void { /// stopping picks one. Everything that makes that work is already above; this /// is the same eight lines pointed at a different list. /// -/// GUI only, and the body says so rather than the signature: fonts.list and -/// the FontSel origin both exist only there, and a comptime-false `if` is what -/// keeps the tty build from analysing either. The dead parameters on that -/// build are the honest shape of "this platform cannot open one". +/// Only where the shell draws its own text, and the body says so rather than +/// the signature: fonts.list and the FontSel origin both exist only there, and +/// a comptime-false `if` is what keeps the tty build from analysing either. +/// The dead parameters on that build are the honest shape of "this platform +/// cannot open one". pub fn openFonts(p: *Pardes, id: usize) !void { - if (pardes.platform == .gui) { + if (pardes.font_picker) { const arena = p.scratch.allocator(); const font_list = fonts.list(arena, null); var len: usize = 0; diff --git a/src/pardes.zig b/src/pardes.zig index 4ce4d101..ec938a6d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -45,6 +45,12 @@ pub const lsp = @import("lsp/lsp.zig"); pub const Platform = enum { tty, gui, web, macos }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); +/// Frontends that draw their own text, and can therefore be told which face to +/// wear. On the tty the font belongs to the terminal emulator and in the +/// browser it belongs to the page, so there the Font builtins are not +/// disabled so much as meaningless — see builtins.zig. +pub const font_picker = platform == .gui or platform == .macos; + /// Native PDF quality is a shell property, but the core owns MuPDF and the /// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text /// quality and asks the renderer to cover either fit axis without upscaling. @@ -4199,6 +4205,18 @@ const PendingPipe = struct { } }; +/// The acme verb the core just performed, for a shell that can answer with +/// something physical. macOS taps the trackpad under the finger that asked +/// (NSHapticFeedbackManager); the SDL shell already does the same thing with a +/// gamepad — `rumble` in src/gui/deck.zig, "a brief gentle ack for +/// execute/look, not a buzz". Two verbs rather than one flag because they +/// deserve to feel different: Exec did something, Look went somewhere. +pub const Haptic = enum { none, exec, look }; + +/// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads +/// the field, so no other shell carries it. +const HapticSlot = if (platform == .macos) Haptic else void; + pub const Pardes = struct { gpa: std.mem.Allocator, image_gpa: std.mem.Allocator, @@ -4273,6 +4291,10 @@ pub const Pardes = struct { /// the PETSCII matcher without it. native_images: bool = false, quit: bool = false, + /// The Look or Exec that has happened and not yet been felt, taken by the + /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs + /// inside one keystroke are still one thing the hand did. + haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, hover_col: u16 = 0, hover_row: u16 = 0, @@ -4431,6 +4453,9 @@ pub const Pardes = struct { p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); + // A config file that opens a file with `Look …` armed the pulse before + // anyone touched anything. Nobody asked for that, so boot is silent. + _ = p.takeHaptic(); return p; } @@ -11603,6 +11628,7 @@ pub const Pardes = struct { /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; + p.noteHaptic(.look); const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the // pane that answers — looking at a thing means being SHOWN it, and a @@ -11770,6 +11796,10 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return null; const cmd = commandText(txt); if (cmd.len == 0) return null; + // Before the builtin dispatch, and only at depth zero: `Exec ls` comes + // back through here as `ls` (executeBuiltinLine holds the depth), and + // one Tab is one thing the hand did, however many words it unwraps to. + if (p.exec_depth == 0) p.noteHaptic(.exec); if (p.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; @@ -12244,6 +12274,7 @@ pub const Pardes = struct { p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); + _ = p.takeHaptic(); // see init: a restored session is not a gesture return p; } @@ -12511,6 +12542,23 @@ pub const Pardes = struct { return p.chrome_animation.isActive(); } + /// Arm the pulse. Look wins a tie because a Look that runs a command + /// (`` @`ls` ``, which is one gesture spelled as both) is felt as the + /// thing the user asked for, not as the shell it happened to need. + fn noteHaptic(p: *Pardes, pulse: Haptic) void { + if (comptime platform != .macos) return; + if (p.haptic == .look) return; + p.haptic = pulse; + } + + /// Take the armed pulse and disarm. The shell calls this once per pump, + /// after the tick that may have set it. + pub fn takeHaptic(p: *Pardes) Haptic { + if (comptime platform != .macos) return .none; + defer p.haptic = .none; + return p.haptic; + } + fn finishThemeInitialization(p: *Pardes) void { p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); p.animate_theme_changes = true; diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index c188827e..8dac7836 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -183,8 +183,16 @@ pub const Harness = struct { /// input just sent: without it, an app still asleep in its event loop looks /// exactly like an app that has finished. See snapshot.zig's waitStable. pub fn pending(self: *Harness) usize { + // Darwin files FIONREAD under the socket ioctls rather than the + // termios group std.posix.T exposes, and encodes it differently + // besides — _IOR('f', 127, int) against linux's flat constant. + // Neither is derivable from the other, so both are named. + const FIONREAD: c_int = switch (@import("builtin").os.tag) { + .linux => posix.T.FIONREAD, + else => 0x4004667f, + }; var n: c_int = 0; - if (posix.system.ioctl(self.master, posix.T.FIONREAD, @intFromPtr(&n)) != 0) return 0; + if (posix.system.ioctl(self.master, FIONREAD, @intFromPtr(&n)) != 0) return 0; return if (n > 0) @intCast(n) else 0; } diff --git a/test/macos-snapshots/boot.golden b/test/macos-snapshots/boot.golden new file mode 100644 index 00000000..28e5a1de --- /dev/null +++ b/test/macos-snapshots/boot.golden @@ -0,0 +1,58 @@ +== snap boot grid=80x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/boot/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== draw boot 720x408 nonblank +== snap resized grid=100x30 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/boot/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== draw resized 900x510 nonblank diff --git a/test/macos-snapshots/boot.snap b/test/macos-snapshots/boot.snap new file mode 100644 index 00000000..e3d4ba14 --- /dev/null +++ b/test/macos-snapshots/boot.snap @@ -0,0 +1,23 @@ +# The whole boot path in one script: pardes_init at the grid the offscreen +# window actually measured, the first shell reaching its prompt, and the +# CoreText pass painting. `draw` is the only command in the suite that touches +# the drawing code at all — everything else reads the core's cell buffer, which +# a draw(_:) that returned on its first line would leave perfectly intact. +# +# The macOS backend takes no argv, so it always boots the way a bare +# `start 30 140` does in test/snapshots: one terminal pane in raw tty mode, +# prompt visible. `$ ` is the prompt the hermetic .bashrc pins, and no chrome +# row carries a `$`, so waiting on one is waiting on the shell. +start 24 80 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +snap boot +draw boot +# The window changing size is the other half of the boot contract: the core +# reflows, the view re-measures, and the second `draw` proves the new size is +# the size that actually got painted. +resize 30 100 +stable 700 20000 +snap resized +draw resized diff --git a/test/macos-snapshots/cwd.golden b/test/macos-snapshots/cwd.golden new file mode 100644 index 00000000..748d523d --- /dev/null +++ b/test/macos-snapshots/cwd.golden @@ -0,0 +1,100 @@ +== snap spawned grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/cwd/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved grid=100x24 cursor=4,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp New Del +| $ cd /tmp +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved-again grid=100x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ / New Del +| $ cd /tmp +| $ cd / +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap idle grid=100x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ / New Del +| $ cd /tmp +| $ cd / +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/cwd.snap b/test/macos-snapshots/cwd.snap new file mode 100644 index 00000000..87f6f9ef --- /dev/null +++ b/test/macos-snapshots/cwd.snap @@ -0,0 +1,32 @@ +# The pane tag has to follow the shell around, because a relative `Look` is +# resolved against it: with a stale cwd, clicking `README.md` after a `cd` +# looks for it in the directory the pane was SPAWNED in and finds nothing. +# +# On linux the host re-reads /proc/<pid>/cwd every frame. macOS asks libproc, +# and asks it only for a pane that just produced output — a `cd` is a command +# and a shell that ran a command prints its next prompt, so nothing else can +# have moved one. This script is what proves that gating is not too clever: +# the tag must be right AFTER the cd and must stay right when nothing happens. +start 24 100 +wait 8000 $ +stable 700 20000 +# The hermetic world puts the shell in <base>/work, so the boot tag names it +# and no absolute path from this machine can leak into the golden. +snap spawned +# `cd` writes nothing but the next prompt: the one and only signal the lazy +# refresh gets. Two of them, because the second has to survive the first +# having already cleared the flag. +text cd /tmp +key enter +wait 8000 $ +stable 700 20000 +snap moved +text cd / +key enter +wait 8000 $ +stable 700 20000 +snap moved-again +# ...and an idle pass must not lose it again: the refresh clears its own flag, +# so a tick with no output must leave the tag exactly where it was. +stable 700 20000 +snap idle diff --git a/test/macos-snapshots/font.golden b/test/macos-snapshots/font.golden new file mode 100644 index 00000000..e61abecd --- /dev/null +++ b/test/macos-snapshots/font.golden @@ -0,0 +1,179 @@ +== snap boot grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap menlo grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap unchanged grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap bigger grid=81x19 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap reset grid=100x24 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap floor grid=225x58 cursor=4,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/font/cwd New Del +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/font.snap b/test/macos-snapshots/font.snap new file mode 100644 index 00000000..71049b55 --- /dev/null +++ b/test/macos-snapshots/font.snap @@ -0,0 +1,49 @@ +# The font picker and the zoom, which are the two things this shell decides +# entirely on its own. Neither is visible in a cell buffer — the core has no +# font and no idea what a point is — so what a snapshot CAN see is the second +# effect of both: a cell that changed size fits a different number of columns +# into the same window, and the core reflows to it. +# +# `font` asserts the face by PostScript name, which is the only direct look at +# the thing under test. +start 24 100 +wait 8000 $ +stable 700 20000 +snap boot + +# Menlo ships with every macOS and ships inside a .ttc, so this is also the +# end-to-end proof of the collection branch in src/fonts.zig: the core walks +# the font directories, matches the name, hands over a path, and the host has +# to pull face 0 out of a file holding four. +command Font Menlo +stable 700 20000 +font Menlo-Regular +snap menlo + +# A name that resolves to nothing must leave the screen alone rather than +# fall back to something. `Font` finds no file, sets no path, and the host is +# never asked to do anything at all. +command Font NoSuchFaceExistsHere +stable 700 20000 +font Menlo-Regular +snap unchanged + +# Zoom is a pure host property: no builtin, no core round trip, just a bigger +# cell and the reflow that follows it. Four points is enough to move the grid +# at any starting size. +zoom 4 +stable 700 20000 +font Menlo-Regular +snap bigger + +# ...and back, which must land on exactly the grid it started from — the whole +# point of "actual size" being a fixed number rather than an undo stack. +zoom reset +stable 700 20000 +snap reset + +# Small enough to be worth checking the clamp does not run away: the range +# floor is 6pt, so this asks for far past it and must simply stop. +zoom -40 +stable 700 20000 +snap floor diff --git a/test/macos-snapshots/keys.golden b/test/macos-snapshots/keys.golden new file mode 100644 index 00000000..cff9484e --- /dev/null +++ b/test/macos-snapshots/keys.golden @@ -0,0 +1,325 @@ +== snap typed grid=80x24 cursor=16,2 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap ran grid=80x24 cursor=4,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap fixed grid=80x24 cursor=16,4 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ echo bra''vo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap second grid=80x24 cursor=4,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +|$ /private/tmp/pardes-macos-e2e/keys/cwd New Del +| $ echo al''pha +| alpha +| $ echo bra''vo +| bravo +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap normal grid=80x24 cursor=4,6 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap moved grid=80x24 cursor=7,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap home grid=80x24 cursor=2,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap end grid=80x24 cursor=6,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap pagedown grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap pageup grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap deleted grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap escaped grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap tabbed grid=80x24 cursor=2,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/keys/cwd New Del +| +| alpha +| +| bravo +| +| bash: bravo: command not found +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/macos-snapshots/keys.snap b/test/macos-snapshots/keys.snap new file mode 100644 index 00000000..b81b88b7 --- /dev/null +++ b/test/macos-snapshots/keys.snap @@ -0,0 +1,59 @@ +# Both halves of pardes_key's vocabulary through PardesView.typeKey: the four +# ASCII controls, the private-use navigation block, and a Ctrl chord. Markers +# are split with '' so the echoed command line can never be the thing a `wait` +# matched — the output has to be what arrived. +start 24 80 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +# raw tty mode: characters and Enter travel to the shell through the pty +text echo al''pha +stable 400 5000 +snap typed +key enter +wait 8000 alpha +stable 700 10000 +snap ran +# backspace before Enter — readline owns the line while the pane is raw +text echo bra''vv +key backspace +text o +stable 400 5000 +snap fixed +key enter +wait 8000 bravo +stable 700 10000 +snap second +# Ctrl-b leaves raw tty mode. From here the same keys are the editor's, which +# is the half of the keyboard the pty never sees. +key c-b +stable 600 8000 +snap normal +key up up left +stable 400 5000 +snap moved +key home +stable 400 5000 +snap home +key end +stable 400 5000 +snap end +key pagedown +stable 400 5000 +snap pagedown +key pageup +stable 400 5000 +snap pageup +key delete +stable 400 5000 +snap deleted +# Escape in normal mode is Last, and with one pane there is nowhere to go — +# which is exactly the no-op worth pinning: a regression here starts jumping. +key escape +stable 400 5000 +snap escaped +# Tab falls through to Exec on the word under the cursor; parked where there is +# no word, this pins the empty case rather than spawning anything. +key tab +stable 700 10000 +snap tabbed diff --git a/test/macos-snapshots/rotate.golden b/test/macos-snapshots/rotate.golden new file mode 100644 index 00000000..54f0d217 --- /dev/null +++ b/test/macos-snapshots/rotate.golden @@ -0,0 +1,156 @@ +== snap results grid=100x30 cursor=4,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap forward grid=100x30 cursor=7,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap forward2 grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap back grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap forward_by_key grid=100x30 cursor=7,7 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/rotate/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== draw rotate 900x510 nonblank diff --git a/test/macos-snapshots/rotate.snap b/test/macos-snapshots/rotate.snap new file mode 100644 index 00000000..17b239f4 --- /dev/null +++ b/test/macos-snapshots/rotate.snap @@ -0,0 +1,63 @@ +# pardes_rotate spends a two-finger twist as the search-step keys. +# +# AppKit reports rotation counterclockwise-positive and the core reads clockwise +# as `n`, so a NEGATIVE delta is the FORWARD step. That inversion is exactly the +# kind of thing a unit test on the accumulator cannot catch and a golden can: +# what the screen does is the assertion. +# +# One notch is 20 degrees (rotation_notch_degrees in src/macos.zig) and the +# remainder is banked, which makes the pair below exact rather than approximate: +# -25 spends one notch and banks -5, then +25 lands on 20 and spends one back. +start 30 100 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +text printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' +key enter +wait 10000 MARK c +stable 700 10000 +key c-b +stable 600 8000 +# `/` types the pattern into the tag; Enter searches the pane's scrollback and +# writes the three hits into a +Search buffer, which is what n/N walk. +text / +stable 400 5000 +text MARK +stable 400 5000 +key enter +wait 10000 @p0: +stable 700 15000 +snap results +# A gesture beginning re-zeros the dial, so travel left over from an earlier +# twist cannot make the first degree of this one jump a match. +rotate 0 +# Clockwise: one notch of `n`, forward onto the first hit and selecting it. +rotate -25 +stable 700 15000 +snap forward +# ...and again, onto the second. Two steps out rather than one, because the +# first hit is where the list already starts: stepping BACK from it has nowhere +# to go, and a golden that cannot move is a golden that proves nothing about +# which way the dial turns. +rotate -25 +stable 700 15000 +snap forward2 +# A new gesture, because the dial banks its remainder exactly like the scroll +# accumulator does: two 25-degree notches leave -10 behind, so reversing INSIDE +# the same twist would have to spend that first and 25 back would move nothing. +# That hysteresis is wanted — it is what stops a thumb resettling on a notch +# boundary from flip-flopping between two matches — and lifting the fingers is +# how a hand clears it. +rotate 0 +# Counterclockwise: `N`. This must land back exactly on `forward`. +rotate 25 +stable 700 15000 +snap back +# And the other half of the contract: a twist IS the keystroke, so typing the +# key the dial claims to send must go the same way the dial went. `n` here must +# reproduce `forward2` — if the dial were sending something else, or the sign +# were inverted, these two would part company. +text n +stable 700 15000 +snap forward_by_key +draw rotate diff --git a/test/macos-snapshots/trackpad.golden b/test/macos-snapshots/trackpad.golden new file mode 100644 index 00000000..82edf68b --- /dev/null +++ b/test/macos-snapshots/trackpad.golden @@ -0,0 +1,311 @@ +== snap ready grid=100x30 cursor=4,8 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap adapter grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap look grid=100x30 cursor=7,5 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap dragged grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap hover grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap scrolled grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap notched grid=100x30 cursor=9,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap exec grid=100x30 cursor=7,10 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX Save New Del +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Search New Del +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap force grid=100x30 cursor=54,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New D /private/tmp/pardes-macos-e2e/trackpad/cwd New D +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Sear +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== snap select grid=100x30 cursor=4,3 +|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill +| /private/tmp/pardes-macos-e2e/trackpad/cwd New D /private/tmp/pardes-macos-e2e/trackpad/cwd New D +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| /tmp/pardes-macos-e2e/trackpad/tmp/pardes-XXXXXX +| 1 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| /private/tmp/pardes-macos-e2e/trackpad/cwd/+Sear +| 1 @p0:2:3-6 x MARK a +| 2 @p0:4:3-6 x MARK b +| 3 @p0:6:3-6 x MARK c +== draw trackpad 900x510 nonblank diff --git a/test/macos-snapshots/trackpad.snap b/test/macos-snapshots/trackpad.snap new file mode 100644 index 00000000..05f46e1f --- /dev/null +++ b/test/macos-snapshots/trackpad.snap @@ -0,0 +1,91 @@ +# The trackpad features, asserted without a trackpad, plus the rest of the +# pointer vocabulary. +# +# Trackpad.button(fingers:) is pure policy with no NSEvent in it, so a script +# can name how many fingers were resting and get back the button a hand would +# have produced: two is acme's button 3 (Look), three or more is button 2 +# (Exec), and a deep press is button 2 by another route. pardes_take_haptic is +# the other half — it is how the pulse the core armed is read back on a machine +# with nothing to feel it with. +# +# Coordinates are 0-based cells, which is what pardes_mouse takes. The tty +# suite's press/release are 1-based SGR and so read one higher. +start 30 100 +wait 8000 New Newcol +wait 8000 $ +stable 700 20000 +# something to look at; split so the echoed command line is not the match +text printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' +key enter +wait 10000 MARK c +stable 700 10000 +# A raw pane hands clicks to the program on the other end. Normal mode is where +# the three buttons mean Select, Exec and Look. +key c-b +stable 600 8000 +snap ready +# THE ADAPTER ITSELF, before any of the decoded paths below. `nsclick` builds a +# real NSEvent and hands it to the view's own rightMouseDown, so this is the one +# command that exercises the code between AppKit and the entry points. It is +# here because an adapter that silently dropped every mouse event once passed +# this entire suite: everything else calls the entry points directly. +nsclick right 4 3 +wait 10000 @p0: +stable 700 15000 +haptic look +snap adapter +# Back to a clean slate for the finger-count cases below. +haptic none +# TWO FINGERS RESTING = button 3 = Look. MARK names no file, so looking at it +# searches, and the hits land in a +Search buffer split under the shell. +fingers 2 4 3 +wait 10000 @p0: +stable 700 15000 +haptic look +snap look +# ...and taking the pulse cleared it +haptic none +# The three press phases one at a time rather than through `click`, which is +# what proves press, drag and release are separate entry points and not a lump. +mouse left press 2 3 +mouse left drag 9 3 +mouse left release 9 3 +stable 700 10000 +snap dragged +# ponytail: `y` here would yank that selection into the core's register and +# fire set_clipboard, which is what the harness's `clipboard <text>` asserts. +# It is not scripted because the expected text has to be READ off this golden +# first — a guessed span would hand the next person a failing script instead of +# a capture. Add the two lines once `snap dragged` shows what got selected. +# A button-less hover: the core tracks it for the link and definition underline. +mouse none motion 4 5 +stable 400 5000 +snap hover +# A trackpad's precise scroll is fractional rows the core accumulates into whole +# wheel presses; a real mouse notch skips the smoothing and arrives as an +# ordinary button. Both spellings, so the two paths stay distinguishable. +scroll -3 4 5 +stable 700 10000 +snap scrolled +wheel wheeldown 4 5 +stable 700 10000 +snap notched +# THREE FINGERS = button 2 = Exec. `New` in the top tag is a builtin, so this +# spawns a pane rather than running something the machine would have to own. +fingers 3 1 0 +stable 700 20000 +haptic exec +snap exec +# A DEEP PRESS is the same button 2 by another route; `Newcol` proves it went +# somewhere different from the click above. +force 5 0 +stable 700 20000 +haptic exec +snap force +# One finger is still an ordinary left press, and selecting is not a verb the +# hand should feel. +fingers 1 4 3 +stable 700 10000 +haptic none +snap select +draw trackpad diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift new file mode 100644 index 00000000..ba0c48f0 --- /dev/null +++ b/test/macos_e2e.swift @@ -0,0 +1,983 @@ +// pardes-macos-e2e: drive the real AppKit shell offscreen with the same kind of +// .snap script the tty backend uses, and diff the captures against goldens. +// +// zig build macos-e2e -Dplatform=macos run every script +// zig build macos-e2e -Dplatform=macos -- --update regenerate goldens +// zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/boot.snap +// +// This is the only test the Swift half has. src/macos.zig's ABI guard proves +// the header and the Zig side agree, and it runs on Linux; nothing there ever +// constructs a PardesView, translates a gesture, or asks CoreText to paint a +// glyph. So the harness links the SHIPPING view — not a stub, and with no test +// hook bolted onto the app — into an offscreen window and calls the same public +// entry points the NSEvent overrides call. What a script exercises is what a +// hand exercises. +// +// The output format is test/snapshot.zig's, deliberately byte-identical in +// shape (`== snap <label> grid=CxR cursor=X,Y` then one `|`-prefixed row each, +// right-trimmed), and the shared command spellings — wait, stable, text, key, +// resize, snap — are its as well. Three shells render one core; a capture that +// cannot be read beside the other two backends' captures is worth much less. +// +// The hermetic world under /tmp/pardes-macos-e2e is not optional. A golden +// taken against the developer's $HOME records the developer's prompt, their +// fish greeting and their config's builtins, and is then a golden for one +// machine. Everything a shell reads is rebuilt per script before pardes_init. + +import AppKit +import Darwin +import Foundation + +/// Where each script's fake world is built. Fixed, not mkdtemp: the pane tag +/// prints this path, so it lands in the goldens verbatim and must not move. +private let workBase = "/tmp/pardes-macos-e2e" +private let defaultScriptDir = "test/macos-snapshots" + +/// pardes_tick drains one round of pty output plus the effects it produced, and +/// a shell writing steadily can keep it returning true for as long as it has +/// something to say. The cap turns "pump until quiet" into a bounded loop; +/// `wait` and `stable` are what actually wait. +private let maxTicksPerPump = 64 + +/// Poll granularity for the two waiting commands. Small enough that a +/// `stable 400` has room to see several quiet slices, large enough not to spin. +private let pollInterval: TimeInterval = 0.01 + +private struct ScriptError: Error { + let message: String +} + +/// What the two C callbacks reach through `userdata`. A class, because a C +/// function pointer cannot capture: a pointer the runtime struct carries is the +/// only way back into Swift state. +private final class Host { + /// Set by `wakeup`, which runs on a pty reader thread. Nothing else in the + /// ABI may be touched from there, so this flag is the entire body — the + /// waiting loops read it to know output is still arriving, and that a quiet + /// slice does not yet mean the screen has settled. + var woke = false + /// The last text the core yanked. The app hands this to NSPasteboard; a + /// hermetic run must not, or a script would be asserting against — and + /// clobbering — whatever the developer last copied. + var clipboard: String? +} + +/// One rendered frame, already flattened the way a capture wants it. +private struct Frame { + let cols: Int + let rows: Int + let cursorX: Int + let cursorY: Int + /// One entry per row, right-trimmed of spaces. + let lines: [String] + + var text: String { lines.joined(separator: "\n") } + var screen: String { lines.map { "|\($0)" }.joined(separator: "\n") } +} + +private func trimTrailingSpaces(_ s: String) -> String { + var out = s + while out.hasSuffix(" ") { out.removeLast() } + return out +} + +// ---------------------------------------------------------------- the driver + +/// Owns the window, the view and the core for the duration of one script. +private final class Driver: PardesViewDelegate { + private let host = Host() + private var window: NSWindow? + private var view: PardesView? + private var booted = false + private var observer: NSObjectProtocol? + private var output = "" + + /// The script's own file name, for error messages. + private let scriptName: String + + init(scriptName: String) { + self.scriptName = scriptName + // The app answers this notification with pump(); so does the harness, + // and for the same reason — the view calls into the core and never + // ticks it, so an unpumped input is an input that visibly did nothing. + // Posting is synchronous, so this fires inside the view call, exactly + // as it does under NSApplication. + observer = NotificationCenter.default.addObserver( + forName: pardesDidInputNotification, object: nil, queue: nil + ) { [weak self] _ in + self?.pump() + } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } + + // MARK: - lifecycle + + private func live() throws -> PardesView { + guard let view, booted else { + throw ScriptError(message: "no core yet — the script needs a `start <rows> <cols>` first") + } + return view + } + + private func start(rows: Int, cols: Int) throws { + // The ABI is a singleton — pardes_init returns 1 while one is up — so a + // script that restarts must tear the old one down first. + shutdown() + // The grid is a pair of u16 all the way down, and CGFloat(cols) * + // cellWidth is a window someone has to allocate a backing store for. A + // typo in a script should say so, not trap in a UInt16 conversion. + guard rows > 0, cols > 0, rows <= 500, cols <= 1000 else { + throw ScriptError(message: "start wants a sane grid, not \(rows)x\(cols)") + } + + // 14pt, the size AppDelegate opens the app at: the cell metrics decide + // the window's pixel size, and `draw` records that size. + let view = PardesView(fontSize: 14) + let size = NSSize( + width: CGFloat(cols) * view.cellWidth, + height: CGFloat(rows) * view.cellHeight) + // Borderless, and never ordered front. A titled window spends the rows + // under its title bar out of the content rect, and the grid the core + // boots at has to be the grid the script asked for. + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: .borderless, + backing: .buffered, + defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + view.setFrameSize(size) + self.window = window + self.view = view + + let got = view.gridSize + guard got.cols == UInt16(cols), got.rows == UInt16(rows) else { + throw ScriptError( + message: "the view measured \(got.cols)x\(got.rows) cells, not the \(cols)x\(rows) the script asked for") + } + + var runtime = pardes_runtime_s( + userdata: Unmanaged.passUnretained(host).toOpaque(), + wakeup: { ud in + // A pty reader thread. One flag and out — see Host.woke. + guard let ud else { return } + Unmanaged<Host>.fromOpaque(ud).takeUnretainedValue().woke = true + }, + set_clipboard: { ud, text, len in + // Main thread, inside pardes_tick, with `text` borrowed for the + // length of the call, so the String has to be a copy. + guard let ud else { return } + let host = Unmanaged<Host>.fromOpaque(ud).takeUnretainedValue() + var yank = "" + if let text, len > 0 { + let bytes = UnsafeRawBufferPointer(start: UnsafeRawPointer(text), count: len) + yank = String(decoding: bytes, as: UTF8.self) + } + host.clipboard = yank + }) + + let rc = pardes_init(&runtime, got.cols, got.rows) + guard rc == 0 else { + throw ScriptError(message: "pardes_init returned \(rc) at \(cols)x\(rows)") + } + booted = true + // Only after init, exactly as AppDelegate orders it: a resize arriving + // before the core exists is a resize that goes nowhere, and the metrics + // seeded below are the core's only notion of cell size. + view.delegate = self + pardesViewDidResize(view) + } + + func shutdown() { + if booted { + pardes_deinit() + booted = false + } + view?.delegate = nil + window?.contentView = nil + window = nil + view = nil + host.clipboard = nil + host.woke = false + } + + // MARK: - PardesViewDelegate + + func pardesViewDidResize(_ view: PardesView) { + let grid = view.gridSize + // Points, not backing-store pixels. The app passes physical pixels + // because the native PDF placement path measures in them; a golden must + // not change when the same script runs on a Retina display, and nothing + // here places a PDF. + let cellW = UInt16(max(1, min(view.cellWidth.rounded(), CGFloat(UInt16.max)))) + let cellH = UInt16(max(1, min(view.cellHeight.rounded(), CGFloat(UInt16.max)))) + pardes_resize(grid.cols, grid.rows, cellW, cellH) + pump() + } + + func pardesViewRequestsPaste(_ view: PardesView) { + // The core's own yank register, never NSPasteboard: reading the system + // clipboard would let whatever the developer last copied into a golden. + let text = host.clipboard ?? "" + text.withCString { pardes_paste($0, text.utf8.count) } + pump() + } + + // MARK: - pumping + + /// Every path into the core ends here. See the ordering contract in + /// docs/macos.md: the input functions only advance the state machine, and + /// the writes, the spawns and the saves all happen in the drain. + /// + /// Deliberately does NOT take the haptic pulse the way the app's pump does. + /// The `haptic` command is its only reader, because a pulse consumed by a + /// background tick is a pulse no script could ever assert. + private func pump() { + var spins = 0 + while pardes_tick() { + spins += 1 + if spins >= maxTicksPerPump { break } + } + // The app does exactly this on every pump (AppDelegate.pump). Without + // it the `Font` builtin would set a path nobody ever collects, and a + // script asserting the new face would be asserting the old one. + if let view, let wanted = pardes_font_take() { + view.adoptFont(path: String(cString: wanted)) + } + } + + /// Hand the run loop a slice. Main-queue work (anything the view defers) + /// runs here, and so does the sleep — see the keep-alive timer in main(), + /// without which run(until:) returns instantly and this becomes a spin. + private func idle() { + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } + + // MARK: - waiting + + private func waitFor(ms: Int, needle: String) throws { + let deadline = Date().addingTimeInterval(Double(ms) / 1000) + while true { + pump() + let frame = readFrame() + if frame.text.contains(needle) { return } + if Date() >= deadline { + throw ScriptError( + message: "waited \(ms)ms for \(needle.debugDescription) and never saw it; the screen was:\n" + + frame.screen) + } + idle() + } + } + + private func waitStable(quietMs: Int, timeoutMs: Int) { + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000) + var key = stateKey() + var quietSince = Date() + while Date() < deadline { + idle() + pump() + let now = stateKey() + // A reader thread that woke us has bytes the next tick has not fed + // in yet, so the screen being unchanged this instant proves nothing. + if now != key || host.woke { + host.woke = false + key = now + quietSince = Date() + continue + } + if Date().timeIntervalSince(quietSince) * 1000 >= Double(quietMs) { return } + } + } + + /// What `stable` compares. The cursor is in it because a caret crossing an + /// otherwise still screen is still the app doing something. + private func stateKey() -> String { + let frame = readFrame() + return "\(frame.cursorX),\(frame.cursorY)\n\(frame.text)" + } + + // MARK: - reading the grid + + private func readFrame() -> Frame { + let count = pardes_frame() + let cols = Int(pardes_frame_cols()) + let rows = Int(pardes_frame_rows()) + // -1 means hidden. Clamped to 0 rather than printed, which is what + // test/web_snapshot.mjs does, so the backends' headers stay comparable. + let cursorX = max(0, Int(pardes_cursor_x())) + let cursorY = max(0, Int(pardes_cursor_y())) + guard cols > 0, rows > 0, Int(count) == cols * rows, let cells = pardes_frame_cells() else { + return Frame(cols: cols, rows: rows, cursorX: cursorX, cursorY: cursorY, lines: []) + } + + var lines: [String] = [] + lines.reserveCapacity(rows) + for row in 0..<rows { + var line = "" + line.reserveCapacity(cols) + for col in 0..<cols { + let cell = cells[row * cols + col] + // A never-painted cell is background only, and a zero-length one + // is the tail half of a wide glyph. Both read as a space, which + // is what the tty oracle's emulator hands back for them too. + if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 || cell.len == 0 { + line.append(" ") + continue + } + // prefix clamps, so a bogus len cannot walk off the eight bytes, + // and String(decoding:) substitutes U+FFFD rather than trapping + // on invalid UTF-8 — a capture has to be able to RECORD garbage, + // not die of it. + let text = withUnsafeBytes(of: cell.text) { raw in + String(decoding: raw.prefix(Int(cell.len)), as: UTF8.self) + } + line.append(text.isEmpty ? " " : text) + } + lines.append(trimTrailingSpaces(line)) + } + return Frame(cols: cols, rows: rows, cursorX: cursorX, cursorY: cursorY, lines: lines) + } + + // MARK: - captures + + private func snap(_ label: String) { + let frame = readFrame() + output += "== snap \(label) grid=\(frame.cols)x\(frame.rows) cursor=\(frame.cursorX),\(frame.cursorY)\n" + for line in frame.lines { output += "|\(stableNames(line))\n" } + } + + /// `New` asks the shell for a temporary document and mkstemp picks six + /// random characters for it (src/temp_file.zig), so the pane tag holding + /// that name is different on every run. TMPDIR is already pinned inside the + /// hermetic world, which makes the DIRECTORY reproducible; this makes the + /// name reproducible. Masked rather than dropped, so a golden still shows + /// that a temp document is what got opened and where. + private func stableNames(_ line: String) -> String { + guard line.contains("pardes-") else { return line } + return line.replacingOccurrences( + of: "pardes-[A-Za-z0-9]{6}", + with: "pardes-XXXXXX", + options: .regularExpression) + } + + /// Render the view the way AppKit would and prove the CoreText pass put + /// something on the screen. `snap` reads the core's cell buffer and would be + /// perfectly happy with a draw(_:) that returned on its first line; this is + /// the only command that touches the drawing code at all. + /// + /// "Not blank" is spelled "not every pixel identical" rather than "not equal + /// to the background colour": it needs no agreement with the view about what + /// that colour is or what channel order the bitmap uses, and a view that + /// painted one flat rect of anything is just as broken as one that painted + /// nothing. + private func draw(_ label: String) throws { + let view = try live() + let bounds = view.bounds + guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + throw ScriptError(message: "draw \(label): the view would not make a bitmap for \(bounds.size)") + } + view.cacheDisplay(in: bounds, to: rep) + guard let data = rep.bitmapData, rep.pixelsWide > 0, rep.pixelsHigh > 0 else { + throw ScriptError(message: "draw \(label): cacheDisplay produced no pixels") + } + + let bytesPerPixel = max(1, rep.bitsPerPixel / 8) + // Not `stride`, which is a standard-library function this would shadow. + // Rows can be padded, so only the first pixelsWide of each are pixels. + let rowBytes = rep.bytesPerRow + var uniform = true + scan: for y in 0..<rep.pixelsHigh { + let row = data + y * rowBytes + for x in 0..<rep.pixelsWide { + let pixel = row + x * bytesPerPixel + for byte in 0..<bytesPerPixel where pixel[byte] != data[byte] { + uniform = false + break scan + } + } + } + // The size recorded is the view's in POINTS, not the bitmap's in device + // pixels: the same script on a Retina machine caches a 2x rep, and a + // golden must not know which display the developer used. + let w = Int(bounds.width.rounded()) + let h = Int(bounds.height.rounded()) + if uniform { + throw ScriptError( + message: "draw \(label): every pixel of the \(w)x\(h) view is identical — the CoreText pass drew nothing") + } + output += "== draw \(label) \(w)x\(h) nonblank\n" + } + + // MARK: - the script + + func run(source: String) throws -> String { + var lineno = 0 + for raw in source.split(separator: "\n", omittingEmptySubsequences: false) { + lineno += 1 + let line = String(raw).trimmingCharacters(in: CharacterSet(charactersIn: " \t\r")) + if line.isEmpty || line.hasPrefix("#") { continue } + do { + try step(line) + } catch let error as ScriptError { + throw ScriptError(message: "\(scriptName):\(lineno): \(line)\n \(error.message)") + } + } + return output + } + + private func step(_ line: String) throws { + let split = line.firstIndex(of: " ") + let cmd = String(split.map { line[..<$0] } ?? Substring(line)) + // Kept verbatim for `text`, `clipboard` and the labels, where the spaces + // are part of the payload. + let rest = split.map { String(line[line.index(after: $0)...]) } ?? "" + let args = rest.split(separator: " ").map(String.init) + + switch cmd { + case "start": + let rows = try int(args, 0, "start rows") + let cols = try int(args, 1, "start cols") + try start(rows: rows, cols: cols) + + case "wait": + let ms = try int(args, 0, "wait timeout") + let needle = rest.drop(while: { $0 != " " }).dropFirst() + guard !needle.isEmpty else { throw ScriptError(message: "wait needs text to look for") } + try waitFor(ms: ms, needle: String(needle)) + + case "stable": + _ = try live() + let quiet = try int(args, 0, "stable quiet") + let timeout = try int(args, 1, "stable timeout") + waitStable(quietMs: quiet, timeoutMs: timeout) + + case "text": + let view = try live() + guard !rest.isEmpty else { throw ScriptError(message: "text needs something to type") } + // Per SCALAR, not per Character: pardes_key takes one codepoint, and + // a grapheme cluster is not one. + for scalar in rest.unicodeScalars { + view.typeKey(scalar.value, text: String(scalar), mods: 0) + } + pump() + + // A builtin command line, the way the menu and the nested-Look socket + // both deliver one. The only way a script can reach a builtin that has + // no key of its own, `Font` among them. + case "command": + _ = try live() + guard !rest.isEmpty else { throw ScriptError(message: "command needs a command") } + pardes_command(rest, rest.utf8.count) + pump() + + // The face actually worn, by PostScript name. Asserted rather than + // snapshotted because a snapshot is the core's cell buffer and the + // core has no font: everything about which face is on screen lives on + // this side of the ABI, so this is the only place it can be checked. + case "font": + let view = try live() + guard view.faceName == rest else { + throw ScriptError(message: "wearing \(view.faceName.debugDescription), wanted \(rest.debugDescription)") + } + + // Cmd+ / Cmd- / Cmd+0, minus the menu. What the grid does afterwards + // is the assertion: a bigger cell fits fewer columns in the same + // window, so a zoom that changed nothing shows up as a golden that + // did not move. + case "zoom": + let view = try live() + if rest == "reset" { + view.zoomReset() + } else { + guard let step = Double(rest) else { + throw ScriptError(message: "zoom takes a point delta or `reset`, got \(rest.debugDescription)") + } + view.zoom(by: CGFloat(step)) + } + pump() + + case "key": + let view = try live() + guard !args.isEmpty else { throw ScriptError(message: "key needs a name") } + for name in args { + let stroke = try keyStroke(name) + view.typeKey(stroke.cp, text: "", mods: stroke.mods) + } + pump() + + case "mouse": + let view = try live() + let button = try mouseButton(try token(args, 0, "mouse button")) + let phase = try token(args, 1, "mouse phase") + let cell = try gridPoint(args, 2, "mouse") + switch phase { + case "press": view.press(button, at: cell) + case "release": view.release(button, at: cell) + case "drag": view.drag(button, to: cell) + // The view's hover entry point carries no button, because a + // button-less move is the only motion AppKit reports as its own + // event. The token is accepted and ignored so that all four phases + // stay spelled the same way. + case "motion": view.motion(to: cell) + default: + throw ScriptError(message: "mouse phase must be press, release, drag or motion, not \(phase)") + } + pump() + + case "click": + let view = try live() + let button = try mouseButton(try token(args, 0, "click button")) + let cell = try gridPoint(args, 1, "click") + view.click(button, at: cell) + pump() + + // A click driven through the NSEvent override rather than the decoded + // entry point, i.e. the AppKit adapter itself. + // + // This exists because a bug lived exactly here and every other test + // walked past it: `click` calls view.click(_:at:), which is downstream + // of mouseDown(with:), so an adapter that dropped every event on the + // floor still passed the whole suite. It dropped them because it asked + // a mouse event for its touch set, which raises; AppKit caught the + // throw inside its own dispatch and abandoned the handler, so nothing + // crashed and nothing logged and no click did anything. + // + // NSEvent.mouseEvent can build the real thing, so the adapter is no + // longer the untestable part. Anything the override does to the event + // that a mouse event does not support now fails here. + case "nsclick": + let view = try live() + guard let window = self.window else { + throw ScriptError(message: "nsclick before start") + } + let kind = try token(args, 0, "nsclick button") + let cell = try gridPoint(args, 1, "nsclick") + // Cell centre, in the flipped view's coordinates, back into the + // window's bottom-left origin that NSEvent wants. + let inView = CGPoint(x: CGFloat(cell.col) * view.cellWidth + view.cellWidth / 2, + y: CGFloat(cell.row) * view.cellHeight + view.cellHeight / 2) + let inWindow = view.convert(inView, to: nil) + let phases: [(NSEvent.EventType, NSEvent.EventType)] = switch kind { + case "right": [(.rightMouseDown, .rightMouseUp)] + case "middle", "other": [(.otherMouseDown, .otherMouseUp)] + default: [(.leftMouseDown, .leftMouseUp)] + } + for (down, up) in phases { + for type in [down, up] { + guard let event = NSEvent.mouseEvent( + with: type, location: inWindow, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: type == down ? 1 : 0) + else { throw ScriptError(message: "nsclick: could not build a \(type) event") } + switch type { + case .rightMouseDown: view.rightMouseDown(with: event) + case .rightMouseUp: view.rightMouseUp(with: event) + case .otherMouseDown: view.otherMouseDown(with: event) + case .otherMouseUp: view.otherMouseUp(with: event) + case .leftMouseDown: view.mouseDown(with: event) + default: view.mouseUp(with: event) + } + } + } + pump() + + case "fingers": + let view = try live() + // The whole two-finger-Look / three-finger-Exec feature, asserted + // without a trackpad: the policy is pure and lives in Trackpad, so a + // script names the finger count and gets the button a hand gets. + // + // The stream is the one macOS actually uses for a multi-finger + // click when its own secondary click is on, which is the default + // and which is what real hardware was observed doing for BOTH two + // and three fingers. Passing RIGHT here is therefore the harder + // case: it is the one where a view that trusted the stream would + // call three fingers a Look. + let count = try int(args, 0, "fingers count") + let cell = try gridPoint(args, 1, "fingers") + let stream = count >= 2 ? PARDES_MOUSE_RIGHT : PARDES_MOUSE_LEFT + view.click(Trackpad.button(stream: stream, fingers: count), at: cell) + pump() + + case "force": + let view = try live() + let cell = try gridPoint(args, 0, "force") + view.click(Trackpad.forceClickButton, at: cell) + pump() + + case "rotate": + let view = try live() + let degrees = try double(args, 0, "rotate degrees") + view.rotate(degrees: CGFloat(degrees)) + pump() + + case "scroll": + let view = try live() + let rows = try double(args, 0, "scroll rows") + let cell = try gridPoint(args, 1, "scroll") + // The horizontal delta is an OPTIONAL FOURTH token, appended rather + // than inserted, so the three-token form still reads the way the + // other suites' scroll commands do. + var cols = 0.0 + if args.count > 3 { cols = try double(args, 3, "scroll cols") } + view.scroll(rows: CGFloat(rows), cols: CGFloat(cols), at: cell) + pump() + + case "wheel": + let view = try live() + let button = try mouseButton(try token(args, 0, "wheel button")) + let cell = try gridPoint(args, 1, "wheel") + view.wheel(button, at: cell) + pump() + + case "resize": + let view = try live() + let rows = try int(args, 0, "resize rows") + let cols = try int(args, 1, "resize cols") + guard rows > 0, cols > 0, rows <= 500, cols <= 1000 else { + throw ScriptError(message: "resize wants a sane grid, not \(rows)x\(cols)") + } + let size = NSSize( + width: CGFloat(cols) * view.cellWidth, + height: CGFloat(rows) * view.cellHeight) + window?.setContentSize(size) + view.setFrameSize(size) + // Pushed by hand rather than trusted to fall out of setFrameSize: + // whether AppKit calls back depends on how the view watches its own + // bounds, and a duplicate resize is a no-op by design (the core + // compares the effective viewport, not the event). + pardesViewDidResize(view) + + case "haptic": + _ = try live() + let want = try hapticPulse(try token(args, 0, "haptic pulse")) + let got = pardes_take_haptic() + guard got == want else { + throw ScriptError(message: "expected the \(hapticName(want)) pulse, got \(hapticName(got))") + } + + case "clipboard": + _ = try live() + guard let yank = host.clipboard else { + throw ScriptError(message: "the core never set the clipboard, wanted \(rest.debugDescription)") + } + guard yank == rest else { + throw ScriptError(message: "clipboard holds \(yank.debugDescription), wanted \(rest.debugDescription)") + } + + case "snap": + _ = try live() + guard !rest.isEmpty else { throw ScriptError(message: "snap needs a label") } + snap(rest) + + case "draw": + guard !rest.isEmpty else { throw ScriptError(message: "draw needs a label") } + try draw(rest) + + default: + throw ScriptError(message: "unknown command \(cmd)") + } + } +} + +// ---------------------------------------------------------------- vocabulary + +/// The keys that carry no text: the four ASCII controls and the private-use +/// navigation block, plus test/snapshot.zig's `c-<ch>`/`a-<ch>` chord spelling. +/// Both name sets are accepted (`esc`/`escape`, `bs`/`backspace`, +/// `pgup`/`pageup`) so a script reads the same in either suite. +private func keyStroke(_ name: String) throws -> (cp: UInt32, mods: UInt32) { + switch name { + case "enter", "ret": return (UInt32(PARDES_KEY_ENTER), 0) + case "escape", "esc": return (UInt32(PARDES_KEY_ESCAPE), 0) + case "tab": return (UInt32(PARDES_KEY_TAB), 0) + case "backspace", "bs": return (UInt32(PARDES_KEY_BACKSPACE), 0) + case "up": return (UInt32(PARDES_KEY_UP), 0) + case "down": return (UInt32(PARDES_KEY_DOWN), 0) + case "left": return (UInt32(PARDES_KEY_LEFT), 0) + case "right": return (UInt32(PARDES_KEY_RIGHT), 0) + case "home": return (UInt32(PARDES_KEY_HOME), 0) + case "end": return (UInt32(PARDES_KEY_END), 0) + case "pageup", "pgup": return (UInt32(PARDES_KEY_PAGE_UP), 0) + case "pagedown", "pgdn": return (UInt32(PARDES_KEY_PAGE_DOWN), 0) + case "delete", "del": return (UInt32(PARDES_KEY_DELETE), 0) + default: break + } + // A chord is the only way to reach the core's Ctrl bindings, and one of them + // — Ctrl-b — is how a terminal pane leaves raw tty mode, which every script + // that clicks on a word first has to do. The text is deliberately empty: the + // core reads the codepoint and the modifier, never a control character. + let scalars = Array(name.unicodeScalars) + if scalars.count == 3, scalars[1] == "-" { + switch scalars[0] { + case "c": return (scalars[2].value, UInt32(PARDES_MOD_CTRL)) + case "a": return (scalars[2].value, UInt32(PARDES_MOD_ALT)) + default: break + } + } + throw ScriptError(message: "unknown key \(name)") +} + +private func mouseButton(_ name: String) throws -> pardes_mouse_button_e { + switch name { + // The honest token for a button-less move. The view's motion(to:) carries + // no button at all, so this only ever reaches `mouse none motion`. + case "none": return PARDES_MOUSE_NONE + case "left": return PARDES_MOUSE_LEFT + case "middle": return PARDES_MOUSE_MIDDLE + case "right": return PARDES_MOUSE_RIGHT + case "wheelup": return PARDES_MOUSE_WHEEL_UP + case "wheeldown": return PARDES_MOUSE_WHEEL_DOWN + case "wheelleft": return PARDES_MOUSE_WHEEL_LEFT + case "wheelright": return PARDES_MOUSE_WHEEL_RIGHT + default: throw ScriptError(message: "unknown mouse button \(name)") + } +} + +private func hapticPulse(_ name: String) throws -> pardes_haptic_e { + switch name { + case "none": return PARDES_HAPTIC_NONE + case "exec": return PARDES_HAPTIC_EXEC + case "look": return PARDES_HAPTIC_LOOK + default: throw ScriptError(message: "unknown haptic pulse \(name)") + } +} + +private func hapticName(_ pulse: pardes_haptic_e) -> String { + if pulse == PARDES_HAPTIC_EXEC { return "exec" } + if pulse == PARDES_HAPTIC_LOOK { return "look" } + return "none" +} + +private func token(_ args: [String], _ index: Int, _ what: String) throws -> String { + guard index < args.count else { throw ScriptError(message: "missing \(what)") } + return args[index] +} + +private func int(_ args: [String], _ index: Int, _ what: String) throws -> Int { + let raw = try token(args, index, what) + guard let value = Int(raw) else { + throw ScriptError(message: "\(what) is not a number: \(raw)") + } + return value +} + +private func uint16(_ args: [String], _ index: Int, _ what: String) throws -> UInt16 { + let raw = try token(args, index, what) + guard let value = UInt16(raw) else { + throw ScriptError(message: "\(what) is not a cell coordinate: \(raw)") + } + return value +} + +/// A `<col> <row>` pair, always in that order and always 0-based — the cell +/// coordinates pardes_mouse takes. The tty suite's SGR commands are 1-based and +/// so read one higher for the same cell. +private func gridPoint(_ args: [String], _ index: Int, _ what: String) throws -> GridPoint { + let col = try uint16(args, index, "\(what) col") + let row = try uint16(args, index + 1, "\(what) row") + return GridPoint(col: col, row: row) +} + +private func double(_ args: [String], _ index: Int, _ what: String) throws -> Double { + let raw = try token(args, index, what) + guard let value = Double(raw) else { + throw ScriptError(message: "\(what) is not a number: \(raw)") + } + return value +} + +// ----------------------------------------------------------- hermetic world + +/// Rebuild the world one script sees, before pardes_init reads any of it. This +/// is test/snapshot.zig's per-script setup spelled in Swift: same fake HOME, +/// same pinned bash prompt, same config, same frozen clock. +private func buildWorld(stem: String) throws -> String { + let fm = FileManager.default + let base = "\(workBase)/\(stem)" + // Recreated, not reused: a file left by a previous run is a file the pane + // tag or an `ls` would show. + try? fm.removeItem(atPath: base) + let home = "\(base)/home" + let work = "\(base)/cwd" + let configHome = "\(home)/.config" + // `New` creates its document under TMPDIR (src/temp_file.zig). Left alone + // that is the per-user /var/folders/... path launchd hands out, which is + // different on every machine and lands verbatim in a pane tag — a golden + // that could only ever pass for whoever generated it. + let tmp = "\(base)/tmp" + for dir in [base, home, work, configHome, tmp] { + try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + + // The shells are started with `--rcfile /tmp/pardes-osc133.bash`, which + // sources $HOME/.bashrc (src/shell_bin.zig) — so this is what pins the + // prompt to `$ ` and keeps the developer's history out of the capture. + try "PS1='$ '\nHISTFILE=\n".write(toFile: "\(home)/.bashrc", atomically: true, encoding: .utf8) + // ...and the config is not empty, because the DEFAULT shell is fish, whose + // prompt carries a hostname and whose greeting carries a version. A golden + // taken against that is a golden for one machine. + try "Shell bash\n".write(toFile: "\(configHome)/pardes", atomically: true, encoding: .utf8) + + // The return value is the failure of a memory allocation and nothing else; + // there is no recovery worth writing, and a golden taken in a world that + // half-built would be nonsense anyway. + _ = setenv("HOME", home, 1) + // Set even though it now points inside the fake HOME: a developer who + // exports XDG_CONFIG_HOME somewhere else would otherwise boot the core with + // their own startup builtins. + _ = setenv("XDG_CONFIG_HOME", configHome, 1) + _ = setenv("TERM", "xterm-256color", 1) + _ = setenv("LC_ALL", "C", 1) + _ = setenv("TMPDIR", tmp, 1) + // A wall clock cannot live in a golden. Everything that would print one + // prints fixed characters instead when this is set. + _ = setenv("PARDES_NOTIME", "1", 1) + guard fm.changeCurrentDirectoryPath(work) else { + throw ScriptError(message: "could not chdir into \(work)") + } + return work +} + +// ---------------------------------------------------------------- the runner + +private func firstDiff(_ golden: String, _ actual: String) -> String { + let g = golden.split(separator: "\n", omittingEmptySubsequences: false) + let a = actual.split(separator: "\n", omittingEmptySubsequences: false) + for n in 0..<max(g.count, a.count) { + let left = n < g.count ? String(g[n]) : "<eof>" + let right = n < a.count ? String(a[n]) : "<eof>" + if left == right { continue } + return " first diff at golden line \(n + 1):\n -\(left)\n +\(right)\n" + } + return "" +} + +private func absolute(_ path: String) -> String { + if path.hasPrefix("/") { return path } + return FileManager.default.currentDirectoryPath + "/" + path +} + +private func snapScripts(in dir: String) -> [String] { + let entries = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [] + return entries.filter { $0.hasSuffix(".snap") }.sorted().map { "\(dir)/\($0)" } +} + +/// One script end to end. Returns true if it passed. +private func runOne(scriptPath: String, update: Bool) -> Bool { + let name = (scriptPath as NSString).lastPathComponent + let stem = (name as NSString).deletingPathExtension + let goldenPath = String(scriptPath.dropLast(".snap".count)) + ".golden" + let actualPath = String(scriptPath.dropLast(".snap".count)) + ".actual" + + let driver = Driver(scriptName: name) + // pardes_deinit is mandatory, not tidiness: the ABI is a singleton, so a + // core left standing makes the NEXT script's pardes_init return 1. + defer { driver.shutdown() } + + let started = Date() + let out: String + do { + let source = try String(contentsOfFile: scriptPath, encoding: .utf8) + _ = try buildWorld(stem: stem) + out = try driver.run(source: source) + } catch let error as ScriptError { + print("FAIL \(stem): \(error.message)") + return false + } catch { + print("FAIL \(stem): \(error)") + return false + } + let took = Int(Date().timeIntervalSince(started) * 1000) + + if update { + do { + try out.write(toFile: goldenPath, atomically: true, encoding: .utf8) + print("UPDATED \(goldenPath) (\(out.utf8.count) bytes)") + return true + } catch { + print("FAIL \(stem): could not write \(goldenPath): \(error)") + return false + } + } + + guard let golden = try? String(contentsOfFile: goldenPath, encoding: .utf8) else { + print("FAIL \(stem): no golden (run with -- --update)") + return false + } + if golden == out { + print("PASS \(stem) (\(took)ms)") + return true + } + try? out.write(toFile: actualPath, atomically: true, encoding: .utf8) + print("FAIL \(stem): differs from golden (actual written to \(actualPath))") + print(firstDiff(golden, out), terminator: "") + return false +} + +@main +struct MacosE2E { + static func main() { + // Before any NSWindow exists: AppKit will not make one without an + // application object, and the policy has to be in place before the first + // window so that nothing ever asks the window server for focus. This has + // to be able to run inside a build, over ssh, beside a developer who is + // typing in another app. + // + // UNVERIFIED: .prohibited is documented as "may not create windows", and + // an offscreen window is nevertheless the standard way to run AppKit + // headless. If `draw` ever reports a blank view on a machine where the + // app itself renders, .accessory is the one-word fallback — it also + // keeps the process out of the Dock, it merely permits activation. + NSApplication.shared.setActivationPolicy(.prohibited) + + // A run loop with no sources attached returns from run(until:) + // immediately, which would turn every poll into a busy spin. This timer + // never does anything; it exists so the deadline is honoured as a sleep. + let keepAlive = Timer(timeInterval: 3600, repeats: true) { _ in } + RunLoop.current.add(keepAlive, forMode: .default) + + var update = false + var positional: [String] = [] + for arg in CommandLine.arguments.dropFirst() { + if arg == "--update" { update = true } else { positional.append(arg) } + } + + // Every path is made absolute up front, because each script chdirs into + // its own hermetic cwd before it runs and the goldens live back here. + var scripts: [String] = [] + for arg in positional.isEmpty ? [defaultScriptDir] : positional { + let path = absolute(arg) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue { + scripts.append(contentsOf: snapScripts(in: path)) + } else { + scripts.append(path) + } + } + if scripts.isEmpty { + print("no .snap scripts found") + exit(1) + } + + var failed = 0 + // Serial, unlike the tty suite's fork-per-script fan-out: the ABI is a + // singleton and the hermetic world is process-global environment plus a + // chdir, so two scripts at once in one process would be two scripts in + // each other's world. + for script in scripts { + if !runOne(scriptPath: script, update: update) { failed += 1 } + } + keepAlive.invalidate() + if failed > 0 { + print("\(failed)/\(scripts.count) macos e2e scripts FAILED") + exit(1) + } + print("all \(scripts.count) macos e2e scripts ok") + exit(0) + } +} diff --git a/tools/embed_zig_sources.zig b/tools/embed_zig_sources.zig index 61427415..9934d09a 100644 --- a/tools/embed_zig_sources.zig +++ b/tools/embed_zig_sources.zig @@ -32,7 +32,16 @@ pub fn main(init: std.process.Init) !void { for (paths) |path| { const full_path = try std.Io.Dir.path.join(gpa, &.{ root, path }); defer gpa.free(full_path); - const contents = try std.Io.Dir.cwd().readFileAlloc(io, full_path, gpa, .limited(64 * 1024 * 1024)); + // `git ls-files` reports the INDEX, not the working tree, and the two + // disagree the moment a file is moved or deleted without staging it — + // an ordinary mid-edit state, and in a jj-colocated repo the normal one + // until the change is exported. A path that is no longer on disk is + // simply not a source to embed; failing the whole web build over it + // means an unrelated refactor breaks a shell it never touched. + const contents = std.Io.Dir.cwd().readFileAlloc(io, full_path, gpa, .limited(64 * 1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; defer gpa.free(contents); try generated.writer.print(" .{{ .path = \"{f}\", .contents = \"{f}\" }},\n", .{ std.zig.fmtString(path), |
