summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 15:58:58 -0300
committerGabriel Schneider <[email protected]>2026-08-11 16:26:20 -0300
commit4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch)
treeface852acae5bc347e6bab2bb5cede501e0ce1d3
parentdedfdea43f0d6c7151c541284c81027969d89032 (diff)
parent89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff)
downloadpardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz
pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump. No textual conflicts, and two things the merge needed: - nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it `void` (glibc hides it behind a versioned symbol std cannot name), so the tty build stopped at 'type void not a function'. statNoFollow keeps fstatat on darwin and asks statx on linux for the same three fields, which is what this file did before the branch generalized it to both platforms. - .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so. linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig also type-checks for aarch64-macos.
-rw-r--r--.gitignore3
-rw-r--r--build.zig278
-rw-r--r--docs/config.md6
-rw-r--r--docs/macos.md687
-rw-r--r--mupdf.zig56
-rw-r--r--src/builtins.zig30
-rw-r--r--src/config.zig6
-rw-r--r--src/fonts.zig (renamed from src/gui/fonts.zig)192
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/macos.zig669
-rw-r--r--src/macos/Info.plist56
-rw-r--r--src/macos/Sources/AppDelegate.swift484
-rw-r--r--src/macos/Sources/PardesView.swift1097
-rwxr-xr-xsrc/macos/build-app.sh45
-rwxr-xr-xsrc/macos/build-e2e.sh52
-rw-r--r--src/macos/icon.swift307
-rw-r--r--src/macos/pardes.h146
-rw-r--r--src/main.zig14
-rw-r--r--src/nested.zig433
-rw-r--r--src/output_pane.zig61
-rw-r--r--src/pardes.zig106
-rw-r--r--src/user_config.zig37
-rw-r--r--test/e2e_harness.zig10
-rw-r--r--test/macos-snapshots/boot.golden58
-rw-r--r--test/macos-snapshots/boot.snap23
-rw-r--r--test/macos-snapshots/cwd.golden100
-rw-r--r--test/macos-snapshots/cwd.snap32
-rw-r--r--test/macos-snapshots/drop.golden94
-rw-r--r--test/macos-snapshots/drop.snap37
-rw-r--r--test/macos-snapshots/font.golden176
-rw-r--r--test/macos-snapshots/font.snap49
-rw-r--r--test/macos-snapshots/keys.golden325
-rw-r--r--test/macos-snapshots/keys.snap59
-rw-r--r--test/macos-snapshots/rotate.golden280
-rw-r--r--test/macos-snapshots/rotate.snap107
-rw-r--r--test/macos-snapshots/trackpad.golden311
-rw-r--r--test/macos-snapshots/trackpad.snap91
-rw-r--r--test/macos_e2e.swift1016
-rw-r--r--test/snapshots/builtins.golden192
-rw-r--r--test/snapshots/builtins.snap10
-rw-r--r--test/snapshots/leader.golden40
-rw-r--r--test/snapshots/ttyhelp.golden72
-rw-r--r--tools/embed_zig_sources.zig11
43 files changed, 7217 insertions, 643 deletions
diff --git a/.gitignore b/.gitignore
index 8b255277..04a16752 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@ 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
+# macOS finder litter; one arrived with the app-bundle branch
+.DS_Store
diff --git a/build.zig b/build.zig
index 84accf93..dea403ca 100644
--- a/build.zig
+++ b/build.zig
@@ -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` the app's swiftc link is given (which is
+/// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the
+/// plist's LSMinimumSystemVersion. The macos branch below derives the last two
+/// from this, 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,23 +22,52 @@ 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;
const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web;
+ // JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per
+ // page, so without this MuPDF decodes nothing and every page comes back
+ // blank. On by default — a viewer that cannot open scans is the more
+ // surprising default — and a switch at all because it is 31 files of
+ // third-party C parsing untrusted input. See the OPENJPEG block in
+ // mupdf.zig.
+ const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true;
const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding;
// wasm: size is the budget
const optimize = if (is_web) .ReleaseSmall else requested_optimize;
@@ -46,6 +84,10 @@ pub fn build(b: *std.Build) void {
const default_grammars: TreeSitterGrammars = if (is_web) .zig else .full;
const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
+ // Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on
+ // the machine that built it and needs no keychain; a Developer ID here is
+ // what makes one launchable on someone else's. See the macos branch below.
+ const macos_identity = b.option([]const u8, "macos-identity", "codesigning identity for pardes.app (default: ad-hoc)") orelse "-";
// The browser shell is a freestanding wasm core plus ordinary web files.
// JavaScript owns the loop and IO; HTML/CSS own rendering.
@@ -327,6 +369,7 @@ pub fn build(b: *std.Build) void {
const mupdf = mupdf_build.add(b, io, mupdf_dep, .{
.target = target,
.optimize = c_optimize,
+ .jpx = enable_jpx,
});
const mupdf_mod = b.createModule(.{
.target = target,
@@ -630,11 +673,12 @@ pub fn build(b: *std.Build) void {
// draws the cell grid with CoreText. See docs/macos.md.
//
// The Zig half is ordinary POSIX and builds anywhere, which is what
- // makes the boundary testable without a Mac — only `macos-app` needs
- // one. Deliberately NOT here: an xcframework, lipo, an Xcode project
- // and codesigning. Those exist to ship a signed universal bundle, and
- // this is a dev build; ghostty's src/build/GhosttyXCFramework.zig is
- // the map when distribution matters.
+ // makes the boundary testable without a Mac — only the bundle needs
+ // one. Deliberately NOT here: an xcframework, lipo and an Xcode
+ // project. The first two are for a UNIVERSAL binary and this ships
+ // arm64; the third is a second build system to keep in step for what
+ // a plist and three install steps already do. ghostty's
+ // src/build/GhosttyXCFramework.zig is the map if that day comes.
const header = b.addTranslateC(.{
.root_source_file = b.path("src/macos/pardes.h"),
.target = target,
@@ -655,14 +699,184 @@ 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).
+ const archive: ?std.Build.LazyPath =
+ if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null;
+ // The one artifact everything downstream links. Named as a step rather
+ // than folded into the install step so the e2e link below can wait for
+ // the LIBRARY without also waiting for the app bundle.
+ const install_lib: *std.Build.Step = if (archive) |a|
+ &b.addInstallLibFile(a, "libpardes.a").step
+ else
+ &b.addInstallArtifact(lib, .{}).step;
+ b.getInstallStep().dependOn(install_lib);
b.installFile("src/macos/pardes.h", "include/pardes.h");
- const app = b.addSystemCommand(&.{"src/macos/build-app.sh"});
- app.addArg(b.getInstallPath(.prefix, ""));
- app.has_side_effects = true; // writes a bundle outside the cache
- app.step.dependOn(b.getInstallStep());
- b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)").dependOn(&app.step);
+ // ---- pardes.app ----
+ //
+ // A bundle is a directory with a plist, a binary and an icon in it, so
+ // it is assembled HERE rather than by a script the build shells out to.
+ // Every input is a file the graph knows — the archive, the three Swift
+ // sources, the header, the plist, the icon generator — which is what
+ // makes the app rebuild when one of them moves and stay untouched when
+ // none does. The script this replaced took an install PREFIX and
+ // re-derived its inputs from whatever happened to be sitting in
+ // zig-out, so it could neither be cached nor be wrong out loud.
+ const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)");
+ const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)");
+ if (archive) |lib_archive| {
+ // The deployment target, spelled once (macos_min_version) and given
+ // to all three things that have to agree about it: the archive was
+ // built for it, this triple is what writes LC_BUILD_VERSION — the
+ // thing dyld actually enforces — and the plist key below is the
+ // claim Finder reads. Apple spells aarch64 "arm64".
+ const apple_arch: []const u8 =
+ if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch);
+ const triple = b.fmt("{s}-apple-macos{d}.{d}", .{
+ apple_arch, macos_min_version.major, macos_min_version.minor,
+ });
+ const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor });
+
+ // swiftc compiles the shell the way zig compiled the core. Pinning
+ // -O here meant the ordinary build shipped an optimized shell
+ // around a Debug core, which costs 5x a frame and reads as "the mac
+ // backend is slow" rather than "you built Debug": one frame at
+ // 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a
+ // ReleaseFast one, 4.1 ms of it in pardes_frame alone.
+ const swift_mode: []const u8 = switch (optimize) {
+ .Debug => "-Onone",
+ .ReleaseSmall => "-Osize",
+ .ReleaseFast, .ReleaseSafe => "-O",
+ };
+
+ // -import-objc-header rather than a module map: the header is
+ // consumed straight from the source tree, so there is nothing to
+ // stage and nothing to keep in sync. -lc++ because ghostty-vt pulls
+ // in simdutf and highway; the Zig side bundles compiler_rt and
+ // ubsan_rt into the archive above, so the C++ runtime is all this
+ // link still has to supply.
+ const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" });
+ link.addFileArg(b.path("src/macos/pardes.h"));
+ link.addArg("-o");
+ const app_bin = link.addOutputFileArg("pardes");
+ for ([_][]const u8{ "main.swift", "AppDelegate.swift", "PardesView.swift" }) |src|
+ link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src})));
+ link.addFileArg(lib_archive);
+ link.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics" });
+
+ // The icon is generated, not committed: the mark is drawn out of
+ // the same palette PardesView.swift renders cells with, so a colour
+ // that moves there moves here on the next build instead of a binary
+ // blob quietly disagreeing with the app it ships in. Compiled alone
+ // because the file is top-level code — one file, one module.
+ const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" });
+ const icon_bin = icon_build.addOutputFileArg("pardes-icon");
+ icon_build.addFileArg(b.path("src/macos/icon.swift"));
+ const icon_run = std.Build.Step.Run.create(b, "pardes-icon");
+ icon_run.addFileArg(icon_bin);
+ const icon_dir = icon_run.addOutputDirectoryArg("Resources");
+
+ // One version, two consumers: LSMinimumSystemVersion is stamped
+ // from the same string the link above enforces, so a bumped
+ // deployment target cannot leave a stale claim behind. plutil reads
+ // the committed plist and writes a new one into the cache — the
+ // source file is never mutated, which is what PlistBuddy did.
+ const plist = b.addSystemCommand(&.{
+ "plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o",
+ });
+ const plist_out = plist.addOutputFileArg("Info.plist");
+ plist.addFileArg(b.path("src/macos/Info.plist"));
+
+ const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes");
+ const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist");
+ // CFBundleIconFile names this without its extension; without both
+ // halves the Dock shows the generic blank page.
+ const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns");
+
+ // Gatekeeper. Ad-hoc by default, which is what an arm64 bundle
+ // needs to launch at all and needs no keychain; -Dmacos-identity=
+ // names a Developer ID for a bundle that leaves this machine, and
+ // only then are the hardened runtime and a trusted timestamp worth
+ // asking for — both are notarization's requirements rather than a
+ // signature's, and --timestamp on an ad-hoc signature is an error.
+ //
+ // It signs the DIRECTORY, so it must follow all three installs: a
+ // signature taken before the icon lands is a signature the icon
+ // then breaks. Always runs, because the bundle it edits lives
+ // outside the cache and zig cannot know what is in it.
+ const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity });
+ if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" });
+ sign.addArg(b.getInstallPath(.prefix, "pardes.app"));
+ sign.has_side_effects = true;
+ sign.step.dependOn(&install_app_bin.step);
+ sign.step.dependOn(&install_plist.step);
+ sign.step.dependOn(&install_icon.step);
+ app_step.dependOn(&sign.step);
+
+ // ...and the thing you hand someone. UDZO is the compressed
+ // read-only image every mac already knows how to open; the app
+ // inside it carries the signature made above, which is what
+ // survives the copy out.
+ const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" });
+ dmg.addArg(b.getInstallPath(.prefix, "pardes.app"));
+ dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg"));
+ dmg.has_side_effects = true;
+ dmg.step.dependOn(&sign.step);
+ dmg_step.dependOn(&dmg.step);
+
+ // The app is part of an ORDINARY build rather than a verb to
+ // remember: `zig build -Dplatform=macos` leaves a launchable,
+ // signed bundle in zig-out beside the library it was linked from.
+ // The dmg stays opt-in — it is for handing over, not for running.
+ b.getInstallStep().dependOn(&sign.step);
+ } else {
+ // The library a bundle links has to BE a Mach-O one, and libtool
+ // is Apple's. Cross-building the ABI for another host is supported
+ // and tested (the Linux dev loop in docs/macos.md); assembling a
+ // bundle out of it is not.
+ const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native");
+ app_step.dependOn(&why.step);
+ dmg_step.dependOn(&why.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 the app's own link uses, because it must be the
+ // same link — it waits on the LIBRARY rather than the whole install, so
+ // running the suite does not also assemble and sign a bundle it never
+ // opens.
+ 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(install_lib);
+ 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 +1137,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/config.md b/docs/config.md
index cba7e5fc..7b35586c 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -8,6 +8,12 @@ Native pardes builds read a per-user `pardes` file before the first frame:
- Windows: `%LOCALAPPDATA%\pardes`, with `%USERPROFILE%\AppData\Local\pardes`
as the fallback.
+`Config` (`SPC f c`, or the word executed anywhere) prints the resolved path
+into a `+Config` output pane, so the machine answers this rather than the list
+above. The path is printed whether or not a file is there — that is the case
+you ask in — and the row is ordinary text, so a right click on it opens the
+file.
+
The browser build has no local user-config path and does not load this file.
The format is one existing builtin command per line, using the same spelling
diff --git a/docs/macos.md b/docs/macos.md
index e430f677..7f695000 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,402 @@ 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 10°, 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° was
+more than a wrist gives without thinking about it and made the dial feel stuck,
+where 10° is still a deliberate turn and 36 steps to a revolution.
+
+### Momentum
+
+`pardes_rotate_end` says the fingers came off, and how fast they were moving
+when they did decides everything. AppKit gives rotation no momentum phase of
+its own — `momentumPhase` belongs to scroll — so the release speed is measured
+here, off the monotonic clock, as a smoothed degrees-per-second over the event
+stream.
+
+The curve is the point. Momentum ramps up **from zero** at a 70°/s floor rather
+than switching on at it:
+
+```zig
+excess = min(|speed| - rotation_fling_floor, rotation_fling_max)
+```
+
+A plain threshold would hand out two free notches the instant it was crossed,
+and the same gesture a hair quicker jumping twice as far is how a control stops
+feeling like a control. So a slow, deliberate turn coasts not a little but not
+at all, and the harder it is thrown the further it goes — bounded, by the cap,
+at about eleven matches for the hardest flick a trackpad can report.
+
+Two things stop a fling that was never thrown. A release more than 90 ms after
+the last motion event is a hand that *stopped* and then lifted, which is the
+most deliberate twist there is and the one a stale velocity sample would fling
+hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in
+progress, the way a hand catches a dial.
+
+The coast itself is spent by `pardes_tick`, one fixed 1/60 step per tick with a
+0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so
+it rides the same 16 ms re-pump a theme transition does and needs no clock of
+its own. Fixed rather than measured on purpose: one fling then spends the same
+travel every time, which is what lets `rotate.snap` assert it instead of
+asserting the machine's timer jitter.
+
+Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s,
+under the floor) and asserts the screen is byte-identical across the release,
+then at 12° per 5 ms and asserts it is not. The list it walks is twenty-four
+hits rather than three for a reason worth keeping: `n` at the last match has
+nowhere to go, so on a short list the hardest possible flick and no flick at
+all produce the same screen — a golden that would have passed before momentum
+existed.
+
+## A drop is a click plus Look
+
+Files dragged onto the grid open beside the pane they were dropped on.
+
+Finder and the Dock already reached the app through `application(_:open:)`, but
+that path cannot say *where* — it opens next to whichever pane happened to have
+focus. A drop knows where the hand was, and in acme that is the whole
+difference, because `Look` places the document relative to the pane it runs in.
+
+So the definition is exactly two things the hand could have done itself: the
+pointer's cell gets a left press and release, focusing that pane the way a
+click there would, and then the ordinary `Look` builtin runs in it. Drop on a
+tag and you clicked a tag. **No drop concept was added to the core**, and there
+is no case here to special-case.
+
+`performDragOperation` decodes the pasteboard and the location and then calls
+`drop(_:at:)`, one call below the event, for the reason every gesture in this
+file is split that way: `NSDraggingInfo` is a protocol with a dozen members and
+no public conformer, so a test that had to build one would be testing its own
+stub. `test/macos-snapshots/drop.snap` drives that entry point and asserts the
+placement rather than the opening — the second file is dropped *inside the pane
+the first one opened* and has to land beside it, which is only true if the
+click went where the pointer was.
+
+## The titlebar follows the focused pane
+
+`pardes_active_path` and `pardes_active_dirty` are read once per pump into
+`representedURL`, `title`, and `isDocumentEdited` — the proxy icon you can drag
+and Cmd-click for the path, the filename, and the dot in the close button.
+
+There is no document architecture behind this and deliberately so: no
+`NSDocument`, no save panel, no "do you want to save" on close. Save is a
+builtin, the pane's tag already says so, and this is the same two facts spelled
+where a Mac user looks for them. A terminal or an output buffer is not a
+document, so focus landing on one clears the icon and puts the title back to
+`pardes` rather than leaving a stale file there. PDFs and images do get an
+icon: they are real paths, and a proxy icon is about the file, not about who
+may edit it.
+
+The dirty half needed the one core change in all of this. `File` counted
+`revision` but never recorded which edit was last *written*, so no shell could
+derive "unsaved" — `File.saved_revision` is that watermark, set by `Save` at
+the moment the write is asked for. Nothing in the core renders it and no other
+shell reads it; it exists because a windowed host has somewhere to put the
+answer. It is marked at ask-time rather than on completion because `save_file`
+carries none back, which makes it exactly as honest as the tagline already was.
+
+## 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 -Dplatform=macos` 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.
+
+### The cell is snapped to device pixels, not to points
+
+The grid has to land on whole *device* pixels: the background pass runs with
+antialiasing off (touching fills would otherwise seam at every shared edge), so
+a fractional column boundary makes the rounding wobble by a pixel from column
+to column, and a screen made of tag bars and selections stripes visibly.
+
+That used to be spelled as whole *points*, which on a Retina display asks for
+twice what it needs — half a point already *is* a whole pixel at 2x. The
+difference is not academic. Monaco advances 8.4014pt at 14, so ceiling to 9
+spaced every column **7.1% wider than the face was drawn for**: loose,
+washed-out text that reads as bad rendering rather than as bad spacing.
+`Metrics` now rounds onto `backingScaleFactor`, giving 8.5 — +1.2%. Width
+rounds to nearest (a monospace glyph is drawn to fit its own advance, so the
+half-pixel either way is slack); height rounds up, because losing a pixel off a
+descender is clipping. The ascent is snapped too, so the rules hung off the
+baseline are whole-pixel fills rather than one-pixel bars smeared across two.
+
+The snap is display-dependent, so `viewDidChangeBackingProperties` re-measures:
+a scale change moves no bounds and therefore fires no resize.
+
+What is *not* done, because macOS does not do it: hinting. Apple renders
+outlines faithfully and lets stems fall where they fall, which is why Mac text
+is softer than a hinted Linux or Windows grid, and why `setShouldSmoothFonts`
+is pinned off — smoothing dilates glyphs (measured: +25% lit pixels, +31% ink
+mass) and needs to know the colour behind the glyph, which over a transparent
+theme it cannot.
+
+## Pixel attachments: PDFs and images
+
+`Surface.images` used to be dropped on the floor here, which is why a PDF pane
+showed *nothing at all*: with `native_images` false the core assumes a terminal
+that cannot draw pixels and degrades a document to counted page turns, and this
+host never set it. It does now — this shell draws pixels, which is a fact
+rather than a question (the tty backend has to ask the terminal about
+kitty-graphics support; the SDL one just says yes, as we do).
+
+The transport is `pardes_image_s`, walked with `pardes_frame_images` /
+`pardes_frame_image_list` after each `pardes_frame`, and it is deliberately
+flat: no callbacks, no handles to register or release. Each entry is a
+rasterized page or image plus two rectangles — `src` (the crop of the raster)
+and `dst` (where it lands), both already clipped to the viewport by the core,
+which is what lets a host draw a continuous-scroll page without inventing an
+overflow clip. Geometry is in **physical pixels**, the space `pardes_resize`'s
+`cell_w`/`cell_h` put the core in; only `cell_x`/`cell_y` are in cells.
+
+`serial`, `page` and `revision` together are the cache key, and the point of it
+is what does *not* move them: panning, zooming to fit and scrolling all reuse
+the same raster, so `PardesView` decodes a page once and scrolling costs
+nothing but a `CGContext.draw`. The bytes the core lends are only valid until
+the next `pardes_frame`, so the `CGImage` owns a copy — which is exactly why
+the key has to be good enough that the copy happens when MuPDF re-rasterizes
+and never on an ordinary wheel event. Attachments a frame does not place are
+evicted, or a session that scrolled a long document would hold every page it
+ever showed.
+
+Two details the picture depends on. The pane BODY is still the clip even though
+the geometry is pre-clipped — a page one pixel too tall would otherwise sit on
+a tagline. And `isFlipped` gives a y-down CTM while `CGImage` draws +y up, so
+each attachment is flipped about its own destination rect rather than about the
+view, which keeps the arithmetic in the grid's coordinates.
+
+Turning this on also changes what an IMAGE pane is here: it was the PETSCII
+glyph-art fallback, the same one a terminal without kitty graphics gets, and it
+is now the real pixels.
+
+## Themes, live
+
+Two bugs lived here, and they were the same bug.
+
+`ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick`
+event. The tty and SDL loops call `core.update(.tick)` on their own clocks;
+this host has no loop of its own, so nothing advanced it — `pardes_tick`
+drained ptys and reported `themeAnimationActive()` back without ever stepping
+the transition. The fade therefore never moved and never ended: every tagline
+kept the *previous* theme's colours until the next launch, and the 16 ms
+re-pump in `AppDelegate.pump` spun at 60 Hz for the rest of the session. The
+pump is the clock, so `pardes_tick` steps it.
+
+`pardes_theme_bg` is the other half. The window background behind the titlebar
+and behind a live resize was a hand-agreed `#121212` in two files; it is now
+read from the core, and it carries the theme's *own* background rather than the
+chrome's, because document backgrounds switch the instant the theme does while
+chrome fades. `window.appearance` follows its luminance, so wearing `acme` no
+longer leaves a dark titlebar over a cream grid.
+
+### Transparent themes
+
+A theme with `bg = null` — the curated `dark`, and every vendored
+`*_transparent` — declares no background of its own. In a terminal that means
+"wear whatever the terminal is wearing"; a window has nothing to wear, so
+`pardes_theme_bg` answers `PARDES_COLOR_DEFAULT` and the host goes see-through:
+`window.isOpaque = false`, a clear background colour, and an
+`NSVisualEffectView` (`.underWindowBackground`, `.behindWindow`, `.active`)
+behind the grid. `PardesView` stops painting the ground at all — it *clears*,
+because AppKit does not blank a non-opaque view — and any cell whose background
+is still the default resolves to `bgClear` and is skipped by the run loop.
+Reversed cells are not: a reverse puts the text colour in the background, and
+text is a real colour that paints.
+
+The blur is a **sibling** of the grid inside a plain container, never its
+parent. Hiding a superview hides its subviews, so a nested backdrop drew a
+blank window for every opaque theme the moment it was hidden.
+
## Threading
One core, touched only from the main thread, plus one pty reader task per pane.
@@ -149,66 +551,137 @@ 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
+the app's swiftc link is given, so the two halves of the app cannot disagree about
+how old a macOS they support. On any other host it stays plain native, which is
+what keeps the Linux dev loop below runnable.
+
+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
+zig build -Dplatform=macos # leaves a signed zig-out/pardes.app
+zig build macos-dmg -Dplatform=macos # ...and zig-out/pardes.dmg to hand over
```
-runs `src/macos/build-app.sh`, which is the whole second half:
+The bundle is assembled by `build.zig` itself, not by a script it shells out
+to. An `.app` is a directory with a plist, a binary and an icon in it, and each
+of those is one step whose inputs the build graph knows — so the app rebuilds
+when a Swift source or the archive moves and is left alone when nothing does.
+There is no Xcode project: a hand-written `pbxproj` would be a second build
+system to keep in step for what three `addInstallFileWithDir` calls already do.
+
+- **The plist.** `plutil -replace LSMinimumSystemVersion` reads the committed
+ `src/macos/Info.plist` and writes a stamped copy into the cache. The source
+ file is never mutated, which is what the old in-place `PlistBuddy` call did.
+- **The icon.** The mark is **Glenda**, the Plan 9 rabbit — pardes is an acme,
+ and acme is Plan 9's. `src/macos/icon.swift` is compiled alone (it is
+ top-level code: one file, one module) and run with the bundle's `Resources`
+ as its output directory. She is *drawn*, not traced: four overlapping
+ ellipses filled as one path under nonzero winding for the silhouette, three
+ more punched back out in the ground colour for the eyes and nose. A
+ silhouette rather than an outline because the mark has to survive being
+ twelve pixels across, where an outlined drawing is a grey smudge with a
+ lighter grey inside it — the ears are the whole recognition, and they are the
+ shapes that reach furthest from the mass. Generated rather than committed, so
+ the palette stays in step with the one `PardesView` draws with (ground
+ `defaultBG`, Glenda `defaultFG`, the strip above her the tag bar, the block
+ cursor at the end of it `ansi16[11]`), and there is no binary blob in the tree
+ to disagree with the app it ships in.
+- **The link.** The same swiftc invocation as before, with the optimize mode
+ following `-Doptimize` so both halves of the app are built the same way:
```sh
-swiftc -O -import-objc-header src/macos/pardes.h \
- -o zig-out/pardes.app/Contents/MacOS/pardes \
- src/macos/Sources/*.swift \
- zig-out/lib/libpardes.a -lc++ \
+swiftc -O -target arm64-apple-macos13.0 \
+ -import-objc-header src/macos/pardes.h \
+ -o <cache>/pardes \
+ src/macos/Sources/{main,AppDelegate,PardesView}.swift \
+ <cache>/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.
+
+## Distribution
+
+`codesign` runs last, over the finished directory — a signature taken before
+the icon lands is a signature the icon then breaks — and it is part of the
+ordinary build, because an unsigned arm64 bundle does not launch at all. The
+default identity is ad-hoc (`-`), which needs no keychain and is enough for the
+machine that built it. For a bundle that leaves this machine:
+
+```sh
+zig build macos-dmg -Dplatform=macos -Doptimize=ReleaseFast \
+ -Dmacos-identity="Developer ID Application: Your Name (TEAMID)"
+```
+
+A real identity also gets `--options runtime` and `--timestamp`, which are
+notarization's requirements rather than a signature's (`--timestamp` on an
+ad-hoc signature is an error, which is why it is conditional). `macos-dmg`
+wraps the signed bundle in a compressed read-only UDZO image — the format every
+Mac already knows how to open, and the signature survives the copy out of it.
+
+Notarization itself is one command away and deliberately not wired in, because
+it needs credentials and the network: `xcrun notarytool submit zig-out/pardes.dmg
+--keychain-profile <profile> --wait`, then `xcrun stapler staple`.
-No Xcode project, no xcframework, no `lipo`, no codesigning — ghostty has all four
-(`macos/Ghostty.xcodeproj`, `src/build/GhosttyXCFramework.zig`, the entitlements
-files), and every one of them exists for *distribution*: a universal binary for
-two architectures, a framework other targets can consume, a signature and
-notarization for Gatekeeper. A dev build that runs on the machine that compiled
-it needs none of it. They are the named upgrade path, in that order: `lipo` when
-a second architecture matters, an xcframework when something other than this app
-links the core, codesigning and entitlements the day it is handed to someone
-else.
+Still not here: an xcframework and `lipo`. Ghostty has both
+(`src/build/GhosttyXCFramework.zig`), and they exist for a universal binary and
+for letting something other than this app link the core. This ships arm64.
-## The Linux dev loop
+## Testing
-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:
+Three layers, and each one exists because the layer above it cannot reach where
+it goes.
+
+**The Linux dev loop.** The Zig half of this backend is plain POSIX. `forkpty`,
+`read`, `write`, `ioctl(TIOCSWINSZ)` and `/usr/bin/open` differ from the tty
+backend by a string constant. So `-Dplatform=macos` compiles on a Linux host,
+and its tests run there:
```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 +689,80 @@ 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> [gap_ms]`, `rotate_end`,
+`drop <path> <col> <row>`,
+`scroll`, `haptic <none|exec|look>` and `draw`. The dial's two extras are what
+make momentum testable at all: the optional gap is a real sleep before the
+event, so a script can say how FAST the dial is being turned, and `rotate_end`
+is the release the fling is measured from. 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 -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` while the Zig core followed `-Doptimize`, so the ordinary
+build 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 from `-Doptimize` into the swiftc link, 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 +779,35 @@ 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.** Ad-hoc codesigning and a DMG are wired in; a Developer ID
+ is one `-Dmacos-identity=` away and notarization one `notarytool` call. Still
+ absent: a universal binary, bundled fonts, localization. The app has an icon,
+ a plist that says what it opens, a deployment target it actually enforces and
+ a signature; the arm64-only slice is the deliberate remaining gap.
diff --git a/mupdf.zig b/mupdf.zig
index a407ea09..400e5ee3 100644
--- a/mupdf.zig
+++ b/mupdf.zig
@@ -8,11 +8,17 @@ const std = @import("std");
pub const Result = struct {
dependency: *std.Build.Dependency,
library: *std.Build.Step.Compile,
+ /// Whether this archive carries the JPEG 2000 decoder. Remembered rather
+ /// than re-derived because `linkTo` has to hand consumers the SAME
+ /// preprocessor view the archive was built with — `FZ_ENABLE_JPX` reaches
+ /// the public headers, and a consumer that disagrees is an ODR bug that
+ /// only shows up as a wrong struct layout at runtime.
+ jpx: bool,
/// Give a Zig module the same preprocessor view as the library, expose the
/// public C headers to @cImport, and propagate the static link.
pub fn linkTo(self: Result, module: *std.Build.Module) void {
- configureModule(module, self.dependency);
+ configureModule(module, self.dependency, self.jpx);
// MuPDF's public context headers select the lock-debugging contract
// from NDEBUG. Every consumer must therefore agree with the archive,
// even when its own Zig optimization/safety mode differs.
@@ -24,13 +30,16 @@ pub const Result = struct {
pub fn add(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, options: struct {
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
+ /// Compile openjpeg and let MuPDF decode JPEG 2000. See the OPENJPEG
+ /// block below for why this is a switch rather than always-on.
+ jpx: bool = true,
}) Result {
const module = b.createModule(.{
.target = options.target,
.optimize = options.optimize,
.link_libc = true,
});
- configureModule(module, dependency);
+ configureModule(module, dependency, options.jpx);
// MuPDF's upstream release build defines NDEBUG independently of the
// optimizer. Without it, an optimized Zig build still enables Fitz's
// debug-locking/assertion paths. linkTo applies the same public-header
@@ -101,6 +110,40 @@ pub fn add(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, options
},
});
+ // --- OPENJPEG ---
+ //
+ // The JPEG 2000 decoder, and the reason a scanned PDF is not a blank page:
+ // a scan is typically one /JPXDecode image per page, so without this MuPDF
+ // raises "JPX support disabled" for every one of them and hands back a
+ // page with nothing drawn on it. Everything else still works — the pane
+ // opens, the page count is right — which makes it look like a renderer bug
+ // rather than a missing codec.
+ //
+ // A switch rather than always-on because it is 31 files of third-party C
+ // that exists to parse untrusted input, and openjpeg has the CVE history
+ // to match. Default on: a PDF viewer that cannot open scans is the more
+ // surprising default, and the ones that matter are exactly the documents
+ // nobody can retypeset.
+ //
+ // Flags are MuPDF's own OPENJPEG_CFLAGS plus OPENJPEG_BUILD_CFLAGS from
+ // Makelists; the include path is already on the module (configureModule
+ // adds it unconditionally, because encode-jpx.c wants the header even when
+ // the codec is off). -fno-sanitize=undefined for the reason source/fitz
+ // needs it: this is upstream C full of deliberate wrapping arithmetic, and
+ // ReleaseSafe's trap-mode UBSan would turn a legal shift into a crash.
+ if (options.jpx) module.addCSourceFiles(.{
+ .root = dependency.path(""),
+ .files = makeSources(b, makelists, "OPENJPEG_SRC"),
+ .flags = &.{
+ "-std=gnu11",
+ "-fno-sanitize=undefined",
+ "-DOPJ_STATIC",
+ "-DOPJ_HAVE_INTTYPES_H",
+ "-DOPJ_HAVE_STDINT_H",
+ "-DMUTEX_pthread=0",
+ },
+ });
+
const library = b.addLibrary(.{
.name = "mupdf",
.linkage = .static,
@@ -109,7 +152,7 @@ pub fn add(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, options
if (options.target.result.os.tag != .windows)
module.linkSystemLibrary("m", .{});
- return .{ .dependency = dependency, .library = library };
+ return .{ .dependency = dependency, .library = library, .jpx = options.jpx };
}
/// Add a link-complete smoke test. Unlike merely producing an archive, this
@@ -203,7 +246,7 @@ pub fn addProbe(b: *std.Build, result: Result, options: struct {
return &run.step;
}
-fn configureModule(module: *std.Build.Module, dependency: *std.Build.Dependency) void {
+fn configureModule(module: *std.Build.Module, dependency: *std.Build.Dependency, jpx: bool) void {
module.addIncludePath(dependency.path("include"));
module.addIncludePath(dependency.path("source/fitz"));
module.addIncludePath(dependency.path("source/pdf"));
@@ -236,8 +279,11 @@ fn configureModule(module: *std.Build.Module, dependency: *std.Build.Dependency)
.{ "FZ_ENABLE_BARCODE", "0" },
.{ "FZ_ENABLE_BROTLI", "0" },
.{ "FZ_ENABLE_ICC", "0" },
- .{ "FZ_ENABLE_JPX", "0" },
}) |macro| module.addCMacro(macro[0], macro[1]);
+ // The one codec that is a build option rather than a fixed answer, and it
+ // reaches the public headers — so consumers get it through linkTo from the
+ // archive's own `jpx`, never re-derived. See the OPENJPEG block in add().
+ module.addCMacro("FZ_ENABLE_JPX", if (jpx) "1" else "0");
module.addCMacro("OCR_DISABLED", "1");
module.addCMacro("TOFU", "1");
module.addCMacro("TOFU_CJK", "1");
diff --git a/src/builtins.zig b/src/builtins.zig
index 3ccbdeed..f207ed9f 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;
}
@@ -460,7 +460,13 @@ pub const Ascii = struct {
pub const Save = struct {
pub fn run(c: Ctx) void {
// an output buffer has no file behind it — nothing to write
- if (c.pane.file) |f| if (output_pane.fileTraits(f.output).saves) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } });
+ if (c.pane.file) |*f| {
+ if (!output_pane.fileTraits(f.output).saves) return;
+ c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } });
+ // ...and this edit is now the one on disk. See File.saved_revision
+ // for why the mark goes here rather than after the write.
+ f.saved_revision = f.revision;
+ }
}
};
@@ -536,6 +542,22 @@ pub const Help = struct {
}
};
+/// Where pardes read its startup commands from — the path, printed into an
+/// output buffer, `SPC f c` or the word executed anywhere.
+///
+/// The one question docs/config.md cannot answer, because the answer depends
+/// on the machine: XDG_CONFIG_HOME if it is set and absolute, else
+/// ~/Library/Application Support/pardes on macOS and ~/.config/pardes
+/// everywhere else. Printing it beats documenting it — the row is ordinary
+/// text, so a right click on it opens the file, and when there is no file
+/// there yet the path is still exactly what you needed to know.
+pub const Config = struct {
+ pub const output: OutputTraits = .{ .name = config.config_buffer };
+ pub fn run(c: Ctx) void {
+ output_pane.openConfig(c.p, c.id) catch |err| c.p.reportError(c.id, "config", err);
+ }
+};
+
// ---- search ----
// The two builtins that ASK for something — Find walks file NAMES under this
diff --git a/src/config.zig b/src/config.zig
index 67314fd9..189d6a0e 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -115,6 +115,9 @@ pub const leader_path = paths: {
.New = "fn",
.Find = "ff",
.Grep = "fg",
+ // the config FILE joins the file group: `SPC f c` says where pardes
+ // read (or would read) its startup commands from.
+ .Config = "fc",
.Tutor = "ht",
.Newcol = "cn",
.Delcol = "cd",
@@ -163,7 +166,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);
}
@@ -623,6 +626,7 @@ pub const pipe_marker = " |";
/// these changes only what you read in a tag.
pub const search_buffer = "+Search";
pub const help_buffer = "+Help";
+pub const config_buffer = "+Config";
pub const jumps_buffer = "+Jumps";
pub const themes_buffer = "+Themes";
pub const fonts_buffer = "+Fonts";
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..ef68d7e7 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -28,6 +28,14 @@ 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");
+/// The geometry types the pixel-attachment ABI carries. Behind the same
+/// comptime gate the placements themselves are: a build without MuPDF emits no
+/// attachments, so nothing here is analysed.
+const image = if (pardes.pdf_enabled) @import("image.zig") else struct {};
+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;
@@ -76,6 +84,44 @@ pub const Cell = extern struct {
flags: u8,
};
+/// Sync with: pardes_image_s. One rasterized attachment — a PDF page, or an
+/// image pane's pixels — and where on the grid it goes.
+///
+/// Geometry travels in PHYSICAL PIXELS, because that is the space the core
+/// already computed it in (pardes_resize hands it the physical cell). `cell_x`
+/// and `cell_y` are the pane BODY's origin in cells and the only thing the
+/// host has to multiply out; `dst` is relative to that origin, and `src` is
+/// the crop of the raster to take. The core has already clipped both to the
+/// viewport, which is what lets a host draw a continuous-scroll page without
+/// inventing an overflow clip of its own.
+pub const Image = extern struct {
+ /// pane lifetime, page and raster generation: together the cache key. A
+ /// host keeps its decoded texture while all three hold still, and `fit`,
+ /// panning and scrolling deliberately do not move them.
+ serial: u32,
+ page: u32,
+ revision: u32,
+ cell_x: u16,
+ cell_y: u16,
+ /// the body this attachment may not paint outside of, in cells
+ cell_w: u16,
+ cell_h: u16,
+ dst_x: u32,
+ dst_y: u32,
+ dst_w: u32,
+ dst_h: u32,
+ src_x: u32,
+ src_y: u32,
+ src_w: u32,
+ src_h: u32,
+ /// subpixel vertical displacement a proportional wheel kept
+ offset_y: f32,
+ iw: u32,
+ ih: u32,
+ /// iw * ih * 4 bytes, RGBA8. Borrowed until the next pardes_frame.
+ rgba: [*]const u8,
+};
+
/// Sync with: pardes_runtime_s. Two callbacks, because everything else the
/// core asks for it already does itself — it owns the ptys, and look.openLink
/// hands URLs to /usr/bin/open. Both are optional at the ABI level: a host that
@@ -108,11 +154,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 +206,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 +219,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 +231,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);
@@ -228,16 +281,51 @@ const State = struct {
/// loops from those would walk off the buffer.
frame_cols: u16 = 0,
frame_rows: u16 = 0,
+ /// This frame's pixel attachments, flattened out of Surface.images. Grown
+ /// and reused like `cells`, and emptied by the same failure path — the
+ /// accessors must never describe a different frame than the cell count.
+ images: []Image = &.{},
+ images_len: usize = 0,
ptys: [pardes.MAX_PANES]?Pty = @splat(null),
inbox: Inbox = .{},
/// Per-slot spawn generation, owned by the main thread. A reader carries a
/// copy in every message it posts; anything that no longer matches belongs
/// to a shell this slot has already replaced.
gens: [pardes.MAX_PANES]u32 = @splat(0),
- /// Sub-row wheel distance the core has not been told about yet. The core
- /// moves a whole row at a time, so fractional trackpad travel accumulates
- /// here and is spent as wheel presses — see pardes_scroll.
+ /// 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,
+ /// The dial's angular velocity, in degrees per second. While fingers are
+ /// down this is a running estimate off the event stream; when they lift it
+ /// becomes the fling that `coasting` spends. Zero is a dial at rest.
+ rotate_velocity: f32 = 0,
+ /// When the last rotation event arrived, so the estimate above has a dt.
+ rotate_last_ns: i128 = 0,
+ /// Fingers are off and the dial is still turning. Separate from a nonzero
+ /// velocity because during the gesture that velocity is a MEASUREMENT —
+ /// spending it then would double every twist under the hand making it.
+ rotate_coasting: bool = false,
+ /// 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,
};
@@ -286,8 +374,11 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// run before the host can render a frame — so it is read here, before
// Pardes.init, exactly as src/main.zig does it. The env map is rebuilt from
// libc's environ because a library has no std.process.Init to inherit one.
- if (captureEnv(config_arena.allocator())) |*env|
- opts.startup_config = user_config.load(io, config_arena.allocator(), env);
+ if (captureEnv(config_arena.allocator())) |*env| {
+ const found = user_config.load(io, config_arena.allocator(), env);
+ opts.startup_config = found.bytes;
+ opts.startup_config_path = found.path;
+ }
pardes.image.start(io, allocs.image);
errdefer pardes.image.stop();
@@ -298,6 +389,12 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
const core = try pardes.Pardes.init(allocs.pardes, opts);
errdefer core.deinit();
+ // This host draws pixels. Without it the core assumes a terminal that
+ // cannot, and a PDF pane degrades to counted page turns with nothing on
+ // screen at all — which is exactly what it did. The SDL shell sets the
+ // same flag; the tty one sets it from the terminal's kitty-graphics
+ // capability, because there it is a question rather than a fact.
+ core.native_images = true;
// Shells emit OSC 133 prompt marks through these, which is what makes
// prompt hiding and click-to-move work.
@@ -335,10 +432,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.
@@ -347,6 +486,7 @@ export fn pardes_deinit() void {
// would show up as a leak rather than as the shutdown it actually is.
st.inbox.close(st.gpa);
if (st.cells.len > 0) st.gpa.free(st.cells);
+ if (st.images.len > 0) st.gpa.free(st.images);
st.arena.deinit();
st.core.deinit();
pardes.image.stop();
@@ -364,14 +504,65 @@ export fn pardes_should_quit() bool {
return st.core.quit;
}
+/// Something on screen moves on its own and wants ~60 Hz ticks until it stops:
+/// a theme transition fading, or the rotation dial coasting after a flick.
+/// Both are spent by pardes_tick, so this is the host's only cue to keep
+/// pumping — an idle pardes costs nothing precisely because it says false.
export fn pardes_animating() bool {
const st = &(state orelse return false);
- return st.core.themeAnimationActive();
+ return st.core.themeAnimationActive() or st.rotate_coasting;
+}
+
+/// The colour the host should paint everything the grid does not: the window
+/// background behind the titlebar, and behind every pixel of a live resize the
+/// view has not caught up with yet.
+///
+/// The theme's OWN background, not the chrome's, and so not animated — the
+/// same split every other shell draws. Chrome (taglines, the move box, the
+/// scrollbar) fades between themes over a handful of frames; document
+/// backgrounds switch the instant the theme does, and this is one of those.
+///
+/// PARDES_COLOR_DEFAULT means the active theme declares NO background of its
+/// own (`bg = null`: the curated `dark`, and every vendored `*_transparent`).
+/// In a terminal that means "wear whatever the terminal is wearing"; a window
+/// has nothing to wear, so the host lets its own backdrop through — see the
+/// NSVisualEffectView in AppDelegate.
+export fn pardes_theme_bg() u32 {
+ // Before pardes_init there is no session, but there IS a theme: the ring's
+ // first entry is what the core boots wearing, so answering with it keeps
+ // the window from opening one colour and flipping to another a frame later.
+ const th = if (state) |*st| st.core.theme() else &pardes.themes[0];
+ const bg = th.bg orelse return color_default;
+ return @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2];
+}
+
+/// 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 +575,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,12 +586,39 @@ 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.
- if (st.core.themeAnimationActive()) changed = true;
+ // ...and ADVANCE the transition, which is the whole reason the host keeps
+ // ticking. The tty and SDL loops call `core.update(.tick)` on their own
+ // clocks; this host has no loop of its own, so the pump IS the clock — and
+ // without this the fade never moved: `chromeTheme()` stayed on the OLD
+ // theme's chrome forever, so every tagline kept its previous colours until
+ // the next launch, and `themeAnimationActive()` never went false, so the
+ // 16 ms re-pump in AppDelegate.pump spun for the rest of the session.
+ if (st.core.themeAnimationActive()) {
+ st.core.update(.tick);
+ changed = true;
+ }
+ // ...and the dial, for the same reason and off the same clock: one frame
+ // of coast per tick, decayed, until it is slower than a notch a second.
+ if (st.rotate_coasting) {
+ spendRotation(st, st.rotate_velocity * rotation_fling_step);
+ st.rotate_velocity *= rotation_fling_decay;
+ if (@abs(st.rotate_velocity) < rotation_fling_stop) {
+ st.rotate_velocity = 0;
+ st.rotate_coasting = false;
+ // The remainder dies with the gesture: a banked half-notch
+ // surviving into the next twist is the hysteresis `rotate 0`
+ // exists to clear.
+ st.rotate_lag = 0;
+ }
+ changed = true;
+ }
return changed;
}
@@ -456,13 +675,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 +688,115 @@ 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 — and it
+ // catches a fling still coasting, because a finger back down is how a hand
+ // catches a dial.
+ if (degrees == 0) {
+ st.rotate_lag = 0;
+ st.rotate_velocity = 0;
+ st.rotate_coasting = false;
+ st.rotate_last_ns = monotonicNs();
+ return;
+ }
+ noteRotationVelocity(st, degrees);
+ spendRotation(st, degrees);
+}
+
+/// The fingers lifted. What happens next is decided entirely by how fast they
+/// were moving when they did: `rotationFling` subtracts the floor, so a slow
+/// twist stops dead where it was put and a flick keeps going in proportion to
+/// how hard it was thrown.
+export fn pardes_rotate_end() void {
+ const st = &(state orelse return);
+ const last = st.rotate_last_ns;
+ st.rotate_last_ns = 0;
+ st.rotate_coasting = false;
+ // A hand that turned the dial, STOPPED, and then lifted has released at
+ // rest however fast it was moving before — and the last sample is still
+ // sitting there saying otherwise. Without this the most deliberate twist
+ // of all (turn, look at it, let go) is the one that flings.
+ if (last == 0 or monotonicNs() - last > 90 * std.time.ns_per_ms) {
+ st.rotate_velocity = 0;
+ return;
+ }
+ st.rotate_velocity = rotationFling(st.rotate_velocity);
+ st.rotate_coasting = st.rotate_velocity != 0;
+}
+
+/// Monotonic nanoseconds, the clock lsp_zls.zig already times with. Monotonic
+/// and not REALTIME on purpose: a dial that flung because NTP stepped the wall
+/// clock backwards would be a bug nobody ever reproduces.
+///
+/// Zero on failure, which is also the "no sample yet" sentinel — so a clock
+/// that will not answer makes the dial refuse to fling rather than fling on a
+/// garbage dt.
+fn monotonicNs() i128 {
+ var ts: libc.timespec = undefined;
+ if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec;
+}
+
+/// One event's contribution to the velocity estimate, in degrees per second.
+/// Smoothed, because a single 120 Hz sample of a human wrist is mostly noise
+/// and the fling would otherwise be decided by whichever one happened to land
+/// last.
+fn noteRotationVelocity(st: *State, degrees: f32) void {
+ const now = monotonicNs();
+ const last = st.rotate_last_ns;
+ st.rotate_last_ns = now;
+ st.rotate_coasting = false;
+ if (last == 0 or now == 0) return;
+ const dt_ns = now - last;
+ // A gap this long is a gesture nobody announced the start of, not a slow
+ // one: dividing by it would report a crawl and eat a real fling.
+ if (dt_ns <= 0 or dt_ns > 200 * std.time.ns_per_ms) return;
+ const seconds: f32 = @floatCast(@as(f64, @floatFromInt(dt_ns)) / @as(f64, std.time.ns_per_s));
+ const sample = degrees / seconds;
+ if (!std.math.isFinite(sample)) return;
+ st.rotate_velocity = st.rotate_velocity * 0.35 + sample * 0.65;
+}
+
+/// Turn degrees into whole search steps, keeping the remainder. The one place
+/// the dial reaches the core, so a hand-turned notch and a coasted one are the
+/// same keystroke by construction.
+fn spendRotation(st: *State, degrees: f32) void {
+ 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 {
@@ -491,12 +818,13 @@ export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16)
export fn pardes_frame() u32 {
const st = &(state orelse return 0);
_ = st.arena.reset(.retain_capacity);
- // The three accessors below must never describe a different frame than the
- // count this returns, so a failure empties all of them together rather than
+ // The accessors below must never describe a different frame than the count
+ // this returns, so a failure empties all of them together rather than
// leaving last frame's buffer behind a fresh cols/rows.
st.frame_len = 0;
st.frame_cols = 0;
st.frame_rows = 0;
+ st.images_len = 0;
const surface = st.core.render(st.arena.allocator()) catch |err| {
log.err("render failed: {t}", .{err});
return 0;
@@ -528,9 +856,78 @@ export fn pardes_frame() u32 {
};
if (cell.default) out.text[0] = ' ' else @memcpy(out.text[0..cell.len], cell.grapheme());
}
+ collectImages(st, surface);
return @intCast(count);
}
+/// Flatten Surface.images into the flat C array the host walks.
+///
+/// A dropped attachment is a page that does not draw, never a wrong one, so
+/// every failure here just stops collecting: the frame is still valid, it
+/// simply has fewer pictures in it than the core offered.
+fn collectImages(st: *State, surface: *const pardes.Surface) void {
+ if (comptime !pardes.pdf_enabled) return;
+ if (surface.nimages == 0) return;
+ if (st.images.len < surface.nimages) {
+ const resized = if (st.images.len == 0)
+ st.gpa.alloc(Image, surface.nimages)
+ else
+ st.gpa.realloc(st.images, surface.nimages);
+ st.images = resized catch return;
+ }
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (place.iw == 0 or place.ih == 0 or place.rgba.len == 0) continue;
+ // Continuous documents hand over geometry the core already clipped to
+ // the viewport. Anything else (a static image pane) is the whole
+ // raster scaled into the whole body, which is the same two rectangles
+ // spelled without a crop.
+ const geometry = place.native.geometry orelse image.NativeGeometry{
+ .src = .{ .x = 0, .y = 0, .w = @intCast(place.iw), .h = @intCast(place.ih) },
+ .dst = .{
+ .x = 0,
+ .y = 0,
+ .w = @as(u32, place.w) * st.core.cell_pixels.w,
+ .h = @as(u32, place.h) * st.core.cell_pixels.h,
+ },
+ };
+ if (geometry.dst.w == 0 or geometry.dst.h == 0) continue;
+ if (geometry.src.w == 0 or geometry.src.h == 0) continue;
+ st.images[st.images_len] = .{
+ .serial = place.serial,
+ .page = place.native.page,
+ .revision = place.native.revision,
+ .cell_x = place.x,
+ .cell_y = place.y,
+ .cell_w = place.w,
+ .cell_h = place.h,
+ .dst_x = geometry.dst.x,
+ .dst_y = geometry.dst.y,
+ .dst_w = geometry.dst.w,
+ .dst_h = geometry.dst.h,
+ .src_x = geometry.src.x,
+ .src_y = geometry.src.y,
+ .src_w = geometry.src.w,
+ .src_h = geometry.src.h,
+ .offset_y = place.native.pixel_offset_y,
+ .iw = @intCast(place.iw),
+ .ih = @intCast(place.ih),
+ .rgba = place.rgba.ptr,
+ };
+ st.images_len += 1;
+ }
+}
+
+export fn pardes_frame_images() u32 {
+ const st = &(state orelse return 0);
+ return @intCast(st.images_len);
+}
+
+export fn pardes_frame_image_list() ?[*]const Image {
+ const st = &(state orelse return null);
+ return if (st.images_len == 0) null else st.images.ptr;
+}
+
export fn pardes_frame_cells() ?[*]const Cell {
const st = &(state orelse return null);
return if (st.frame_len == 0) null else st.cells.ptr;
@@ -561,6 +958,77 @@ 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;
+}
+
+/// The FILE behind the focused pane, or null when there is none — a terminal,
+/// an output buffer (`+Search` names a directory, not a document), or nothing
+/// focused at all. A PDF and an image both count: they are real paths on disk,
+/// and the titlebar's proxy icon is about the file, not about who can edit it.
+///
+/// A copy into a static buffer for the reason pardes_font_take keeps one: the
+/// core owns a length and no terminator, C wants a string, and there is one
+/// core. Valid until the next call.
+var active_path_z: [4096:0]u8 = undefined;
+
+export fn pardes_active_path() ?[*:0]const u8 {
+ const st = &(state orelse return null);
+ const path = activeFilePath(st) orelse return null;
+ if (path.len == 0 or path.len >= active_path_z.len) return null;
+ @memcpy(active_path_z[0..path.len], path);
+ active_path_z[path.len] = 0;
+ return &active_path_z;
+}
+
+/// Does the focused pane hold edits that are not on disk? False for everything
+/// that cannot be saved in the first place, which is the same set
+/// pardes_active_path answers null for minus the PDFs and images — those have
+/// a path but no buffer, so they are never dirty.
+export fn pardes_active_dirty() bool {
+ const st = &(state orelse return false);
+ const pane = st.core.panes[st.core.active] orelse return false;
+ const f = if (pane.file) |*x| x else return false;
+ if (f.output != null) return false;
+ return f.revision != f.saved_revision;
+}
+
+fn activeFilePath(st: *State) ?[]const u8 {
+ const pane = st.core.panes[st.core.active] orelse return null;
+ if (pane.file) |*f| return if (f.output == null) f.path else null;
+ if (comptime pardes.pdf_enabled) if (pane.pdfPath()) |path| return path;
+ if (pane.image) |*iv| return iv.path;
+ return null;
+}
+
// ---------------------------------------------------------------- effects
/// Perform the IO the core queued. `threads_ok` is false for the one drain
@@ -812,10 +1280,7 @@ fn encodeAttrs(style: pardes.CellStyle) u16 {
/// Spend accumulated sub-row travel as whole wheel notches, keeping the
/// remainder. The core has no fractional scroll — both other shells do this
-/// same accumulation host-side (stepScroll in gui.zig, the drain loop in
-/// web/app.mjs) — so it lives here and the Swift side stays a translator.
-///
-/// The lag is clamped to one screen's worth so a nonsense delta (an inertial
+/// too — and the clamp is so that an absurd delta (a momentum-phase kinetic
/// fling reported in points, a NaN) cannot spin the emit loop.
fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 {
if (!std.math.isFinite(delta_rows)) return 0;
@@ -826,6 +1291,65 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 {
return whole;
}
+/// One search step per this many degrees of twist. Every notch is a jump to
+/// another match, so it stays coarse enough that a thumb resettling cannot
+/// walk the cursor across the file — but 20 degrees was more than a wrist
+/// gives without thinking about it, and the dial felt stuck. Ten is still a
+/// deliberate twist, and 36 steps to a full turn.
+const rotation_notch_degrees: f32 = 10;
+
+/// Where momentum STARTS, in degrees per second — and it starts at zero.
+///
+/// The fling is the release speed MINUS this, so a slow twist coasts not a
+/// little but not at all, and the faster the flick the more there is. A plain
+/// threshold would hand out two free notches the instant it was crossed, which
+/// is the one thing a dial must not do: the same gesture, a hair quicker,
+/// jumping twice as far is how a control stops feeling like a control.
+const rotation_fling_floor: f32 = 70;
+/// ...and the ceiling on what is left after that subtraction. AppKit reports a
+/// thousand degrees a second for one frame of a twitch, and this cap is what
+/// decides how far the hardest possible flick throws the list: 400 deg/s is
+/// about 111 degrees of coast, so eleven matches. Twenty read as the list
+/// getting away from you.
+const rotation_fling_max: f32 = 400;
+/// One pump of coasting. Fixed rather than measured: the host re-pumps at
+/// ~60 Hz for exactly as long as pardes_animating says to, and a fixed step
+/// makes one fling spend the same travel every time — which is what lets a
+/// golden assert it instead of asserting the machine's timer jitter.
+const rotation_fling_step: f32 = 1.0 / 60.0;
+/// Per-step decay. 0.94 at 60 Hz is a little over half a second of coast, the
+/// same order as the trackpad's own inertial scrolling.
+const rotation_fling_decay: f32 = 0.94;
+/// Below this the dial is at rest: one notch a second is not momentum, it is a
+/// list still stepping long after the hand has moved on.
+const rotation_fling_stop: f32 = 18;
+
+/// The velocity a release at `speed` degrees/second actually coasts at, after
+/// the floor is subtracted and the remainder capped. Zero means the twist was
+/// a placement, not a throw — which is most of them.
+///
+/// Total travel follows from it and the decay as a geometric series:
+/// `v * step / (1 - decay)`, i.e. about 0.28 degrees per degree/second. A
+/// 200 deg/s release therefore coasts ~36 degrees, three or four notches.
+fn rotationFling(speed: f32) f32 {
+ const excess = @min(@abs(speed) - rotation_fling_floor, rotation_fling_max);
+ if (excess < rotation_fling_stop) return 0;
+ return std.math.copysign(excess, speed);
+}
+
+/// 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,14 +1381,24 @@ 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_rotate_end), @TypeOf(pardes_rotate_end));
+ 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));
try expectSameAbi(@TypeOf(c.pardes_frame_cols), @TypeOf(pardes_frame_cols));
try expectSameAbi(@TypeOf(c.pardes_frame_rows), @TypeOf(pardes_frame_rows));
+ try expectSameAbi(@TypeOf(c.pardes_frame_images), @TypeOf(pardes_frame_images));
+ try expectSameAbi(@TypeOf(c.pardes_frame_image_list), @TypeOf(pardes_frame_image_list));
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));
+ try expectSameAbi(@TypeOf(c.pardes_active_path), @TypeOf(pardes_active_path));
+ try expectSameAbi(@TypeOf(c.pardes_active_dirty), @TypeOf(pardes_active_dirty));
+ try expectSameAbi(@TypeOf(c.pardes_theme_bg), @TypeOf(pardes_theme_bg));
}
test "pardes.h matches the Zig boundary" {
@@ -878,6 +1412,12 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@offsetOf(c.pardes_cell_s, "attrs"), @offsetOf(Cell, "attrs"));
try expectEqual(@offsetOf(c.pardes_cell_s, "len"), @offsetOf(Cell, "len"));
try expectEqual(@offsetOf(c.pardes_cell_s, "flags"), @offsetOf(Cell, "flags"));
+ // The attachment struct is a wide one and every field is read by hand on
+ // the Swift side, so its layout is checked at both ends rather than at the
+ // two that happen to be easy.
+ try expectEqual(@sizeOf(c.pardes_image_s), @sizeOf(Image));
+ inline for (@typeInfo(Image).@"struct".fields) |field|
+ try expectEqual(@offsetOf(c.pardes_image_s, field.name), @offsetOf(Image, field.name));
try expectEqual(@sizeOf(c.pardes_runtime_s), @sizeOf(Runtime));
try expectEqual(@as(u32, c.PARDES_COLOR_DEFAULT), color_default);
@@ -914,6 +1454,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 +1503,66 @@ 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, 7));
+ try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 5));
+ try expectEqual(@as(f32, 2), 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, -12));
+ try expectEqual(@as(f32, 0), lag);
+
+ // One deliberate half-turn is several matches, not a hundred.
+ lag = 0;
+ try expectEqual(@as(i32, 18), 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));
+}
+
+test "the dial flings in proportion to the release, and not at all when placed" {
+ // The whole point of the curve: momentum ramps UP FROM ZERO at the floor
+ // rather than switching on at it, so no release speed exists where the
+ // same gesture a hair quicker suddenly jumps several matches further.
+ try std.testing.expectEqual(@as(f32, 0), rotationFling(0));
+ try std.testing.expectEqual(@as(f32, 0), rotationFling(40));
+ try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor));
+ // Just over the floor is still nothing: what is left has to beat the
+ // at-rest threshold before it is worth waking the pump for.
+ try std.testing.expectEqual(@as(f32, 0), rotationFling(rotation_fling_floor + 5));
+
+ // ...and past that it is linear in the release speed, both ways.
+ try std.testing.expectEqual(@as(f32, 130), rotationFling(200));
+ try std.testing.expectEqual(@as(f32, -130), rotationFling(-200));
+
+ // A twitch is capped rather than emptying the list.
+ try std.testing.expectEqual(rotation_fling_max, rotationFling(100_000));
+ try std.testing.expectEqual(-rotation_fling_max, rotationFling(-100_000));
+
+ // What that buys, in the units a hand feels: total coast is the geometric
+ // series v*step/(1-decay), so a brisk 200 deg/s release is a few matches
+ // and the hardest flick the cap allows is bounded well short of a hundred.
+ const travel = struct {
+ fn of(speed: f32) f32 {
+ return @abs(rotationFling(speed)) * rotation_fling_step / (1 - rotation_fling_decay);
+ }
+ }.of;
+ try std.testing.expect(travel(200) / rotation_notch_degrees < 5);
+ try std.testing.expect(travel(200) / rotation_notch_degrees >= 3);
+ // ...and the hardest flick a trackpad can report is bounded at about a
+ // dozen matches. This is the number to change if the dial ever feels like
+ // it is getting away from the hand.
+ try std.testing.expect(travel(100_000) / rotation_notch_degrees < 12);
+ try std.testing.expect(travel(100_000) / rotation_notch_degrees > 8);
+}
diff --git a/src/macos/Info.plist b/src/macos/Info.plist
index 9e8f65f7..3d526242 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.zig installs the pardes.icns
+ src/macos/icon.swift draws. 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.zig stamps this from macos_min_version, which is the single
+ source of truth, into a copy of this file; the key must exist for
+ plutil's -replace to have something to replace. -->
<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..cda35ff0 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -1,31 +1,57 @@
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!
private var view: PardesView!
+ /// The blur behind the grid, shown only while the theme declares no
+ /// background of its own. See applyTheme.
+ private var backdrop: NSVisualEffectView!
// One pump chain at a time. pump() re-arms itself while a theme transition
// is in flight and every input pumps as well, so without this a burst of
// keys during a fade would leave one 60 Hz chain per keystroke, all of them
// ticking until the fade ended.
private var pumpScheduled: Bool = false
+ // 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(
@@ -38,18 +64,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// isReleasedWhenClosed on would have AppKit release it out from under
// that reference the moment the close button is pressed.
window.isReleasedWhenClosed = false
- window.contentView = view
+ // The grid and the blur are SIBLINGS in a plain container, not parent
+ // and child. A transparent theme has to show something through the
+ // grid, and AppKit will not blur what is behind a window unless an
+ // NSVisualEffectView asks it to — but the backdrop is hidden for every
+ // theme that brings its own background, and hiding a superview hides
+ // its subviews with it. Nested, an opaque theme drew a blank window.
+ let container = NSView(frame: NSRect(origin: .zero, size: want))
+ container.autoresizesSubviews = true
+ backdrop = NSVisualEffectView(frame: container.bounds)
+ // .underWindowBackground is the material meant for exactly this — the
+ // full-window wash behind content, rather than the sidebar/HUD
+ // materials that carry their own tint. .behindWindow is what samples
+ // the desktop instead of the window's own layers.
+ backdrop.material = .underWindowBackground
+ backdrop.blendingMode = .behindWindow
+ // .active, not .followsWindowActiveState: the grid stays readable when
+ // the window is not key, and a backdrop that flattens to grey on focus
+ // loss makes an unfocused pardes look switched off.
+ backdrop.state = .active
+ backdrop.autoresizingMask = [.width, .height]
+ view.frame = container.bounds
+ view.autoresizingMask = [.width, .height]
+ // Order matters: the blur is BEHIND the grid.
+ container.addSubview(backdrop)
+ container.addSubview(view, positioned: .above, relativeTo: backdrop)
+ window.contentView = container
// 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
+ // What the core is wearing, not a constant agreed by hand — two things
+ // draw and both have to say the same thing: 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 that agreement
+ // the gap flashes on every drag. It also owns window.appearance: a dark
+ // window with a light-mode titlebar reads as a bug, and so does the
+ // reverse the moment someone wears `acme`.
+ applyTheme()
// 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 +135,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 +175,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 +190,316 @@ 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
+ }
+
+ /// Dress the WINDOW in what the core is wearing: the background AppKit
+ /// paints where the view does not (the titlebar, and the strip a live
+ /// resize outruns), and the blur behind a theme that brings no background
+ /// of its own.
+ ///
+ /// Cheap enough to call every pump: the view compares before it dirties
+ /// itself, and the window properties are written only when the answer
+ /// moved. `dressed` is what makes the FIRST call unconditional — a boot
+ /// theme whose background happened to equal the view's starting guess
+ /// would otherwise leave the window in AppKit's default clothes forever.
+ private var dressed = false
+
+ private func applyTheme() {
+ let changed = view.adoptThemeBG(pardes_theme_bg())
+ guard changed || !dressed else { return }
+ dressed = true
+ if let rgb = view.themeBG {
+ backdrop.isHidden = true
+ window.isOpaque = true
+ window.backgroundColor = NSColor(
+ srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255,
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
+ blue: CGFloat(rgb & 0xFF) / 255,
+ alpha: 1)
+ // ...and follow the theme into light mode, so the titlebar, the
+ // traffic lights and the resize cursor stop belonging to a
+ // different application than the grid under them. Luminance off
+ // the same sRGB channels the grid is drawn with.
+ let luma = (0.2126 * CGFloat((rgb >> 16) & 0xFF)
+ + 0.7152 * CGFloat((rgb >> 8) & 0xFF)
+ + 0.0722 * CGFloat(rgb & 0xFF)) / 255
+ window.appearance = NSAppearance(named: luma > 0.5 ? .aqua : .darkAqua)
+ } else {
+ // Transparent: the window stops painting anything of its own and
+ // the blur takes over. isOpaque false is what lets the desktop
+ // reach the backdrop at all — a titled window is opaque by default
+ // and would composite over it.
+ backdrop.isHidden = false
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.appearance = NSAppearance(named: .darkAqua)
+ }
+ }
+
+ /// The focused pane, in the titlebar: the proxy icon macOS lets you drag
+ /// and Cmd-click for the path, and the dot in the close button that means
+ /// unsaved.
+ ///
+ /// A pure read-out — the window says what the core already decided, and
+ /// nothing here can change it. There is no document ARCHITECTURE behind it
+ /// and deliberately so: no NSDocument, no save panel, no "do you want to
+ /// save" on close. Save is a builtin, the pane's tag says so, and this is
+ /// the same two facts spelled where a Mac user looks for them.
+ ///
+ /// Cached, because setting representedURL makes AppKit hit the filesystem
+ /// for the icon and this runs on every pump.
+ private var shownPath: String?
+ private var shownDirty = false
+
+ private func applyDocument() {
+ let path = pardes_active_path().map { String(cString: $0) }
+ if path != shownPath {
+ shownPath = path
+ // A terminal or an output buffer is not a document: no path means
+ // no proxy icon, rather than a stale one from the last file pane.
+ window.representedURL = path.map { URL(fileURLWithPath: $0) }
+ // ...and the title goes with it. A proxy icon beside a title that
+ // names something else reads as a bug, and AppKit will not fill the
+ // title in for us while `title` has been set by hand. The window is
+ // a SESSION and not a document, so it falls back to the app's own
+ // name the moment focus lands somewhere with no file behind it.
+ window.title = path.map { ($0 as NSString).lastPathComponent } ?? "pardes"
+ }
+ let dirty = pardes_active_dirty()
+ if dirty != shownDirty {
+ shownDirty = dirty
+ window.isDocumentEdited = dirty
+ }
}
@objc private func inputArrived(_ notification: Notification) {
@@ -128,7 +510,54 @@ 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
+ // After the tick, because a `Theme` command runs inside it and the
+ // window has to follow the grid in the same frame rather than at the
+ // next launch.
+ applyTheme()
+ applyDocument()
+
+ // 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 +602,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..3d841a60 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,71 @@
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
+let pardesDefaultFG: UInt32 = 0xCC_CC_CC
+let pardesDefaultBG: UInt32 = 0x12_12_12
-private let inputNotification = Notification.Name("pardesDidInput")
+/// Pinned sRGB for rasterized attachments, so a PDF page's bytes mean the same
+/// thing here as they do in the SDL shell. DeviceRGB is the fallback rather
+/// than a crash: a machine with no sRGB profile is not a reason to stop
+/// drawing pages.
+private let sRGB: CGColorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
// UNVERIFIED: kCTFontAttributeName bridged through NSAttributedString.Key. It is
// the same string as .font, but spelling the CoreText key means the value stays
@@ -60,18 +123,41 @@ private func decodeColor(_ encoded: UInt32, _ fallback: UInt32) -> UInt32 {
return encoded & UInt32(PARDES_COLOR_RGB_MASK)
}
-/// `block` means the filled cursor sits on this cell.
+/// 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))!
+ }
+}
+
+/// A background run that must not be painted at all, so the window's own
+/// backdrop shows through. Outside the 24-bit RGB range, so it can never
+/// collide with a real colour, and distinct from the `.max` the run loop
+/// flushes on.
+let bgClear: UInt32 = 0x0100_0000
+
+/// `block` means the filled cursor sits on this cell. `ground` is what a
+/// DEFAULT background resolves to, and `clearGround` asks for those cells to
+/// come back as `bgClear` instead of a colour.
private func resolve(
_ cell: pardes_cell_s,
- block: Bool
+ block: Bool,
+ ground: UInt32,
+ clearGround: Bool
) -> (fg: UInt32, bg: UInt32, alpha: CGFloat, visible: Bool) {
// The core never painted this cell, which is most of the screen most of the
// time, so this branch is the one that has to stay cheap.
if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 {
- return block ? (defaultBG, defaultFG, 1, false) : (defaultFG, defaultBG, 1, false)
+ return block
+ ? (ground, pardesDefaultFG, 1, false)
+ : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false)
}
- var fg = decodeColor(cell.fg, defaultFG)
- var bg = decodeColor(cell.bg, defaultBG)
+ let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT)
+ var fg = decodeColor(cell.fg, pardesDefaultFG)
+ var bg = decodeColor(cell.bg, ground)
// 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.
@@ -85,6 +171,9 @@ private func resolve(
// The other shells scale the channels by 6/10. Over a dark background alpha
// lands in the same place and costs one blend instead of three multiplies.
let alpha: CGFloat = cell.attrs & UInt16(PARDES_ATTR_DIM) != 0 ? 0.6 : 1
+ // Only an UNREVERSED default background is the ground. A reverse puts the
+ // text colour there, and text is a real colour that paints.
+ if clearGround && bgDefault && !reverse { bg = bgClear }
return (fg, bg, alpha, visible)
}
@@ -97,6 +186,134 @@ 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]]
+
+ /// Round `v` onto the backing grid: `scale` is the display's
+ /// backingScaleFactor, so at 2x this lands on half-points, which are whole
+ /// device pixels.
+ private static func snap(_ v: CGFloat, _ scale: CGFloat, _ rule: FloatingPointRoundingRule) -> CGFloat {
+ (v * scale).rounded(rule) / scale
+ }
+
+ init(size: CGFloat, path: String?, scale: CGFloat) {
+ let face = Metrics.face(size: size, path: path)
+ let scale = max(1, scale)
+
+ // 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])]
+
+ // The grid has to land on WHOLE DEVICE PIXELS, and that is the whole
+ // constraint — a fractional column boundary makes the background pass
+ // (which runs with antialiasing off, or touching fills seam) wobble by
+ // a pixel from column to column, and on a screen made of tag bars and
+ // selections that stripe is visible.
+ //
+ // Whole POINTS is how that used to be spelled, and on a Retina display
+ // it asks for twice what it needs: half a point IS a whole pixel at 2x.
+ // The difference is not academic — Monaco advances 8.4014pt at 14, so
+ // ceiling to 9 spaced every column 7.1% wider than the face was drawn
+ // for, which is loose, washed-out text that reads as bad rendering.
+ // Snapped to the backing grid it is 8.5, i.e. +1.2%.
+ //
+ // Width rounds to NEAREST — a monospace glyph is drawn to fit its own
+ // advance, so the half-pixel either way is slack — while height rounds
+ // UP, because losing a pixel off a descender is clipping.
+ let snap = Metrics.snap
+ fonts = faces
+ // The ascent lands on a pixel for a second reason: it is the baseline's
+ // offset inside the cell, so the rules hung off it are whole-pixel
+ // fills rather than one-pixel bars smeared across two rows.
+ ascent = max(1 / scale, snap(CTFontGetAscent(face), scale, .toNearestOrAwayFromZero))
+ cellWidth = max(1 / scale, snap(advance(face, 0x4D), scale, .toNearestOrAwayFromZero))
+ cellHeight = max(1 / scale, snap(CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face), scale, .up))
+ ruleThickness = max(1 / scale, snap(CTFontGetUnderlineThickness(face), scale, .toNearestOrAwayFromZero))
+ underlineOffset = snap(CTFontGetUnderlinePosition(face), scale, .toNearestOrAwayFromZero)
+ 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 +323,174 @@ 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 backingScaleFactor `metrics` was snapped to, so a move between a
+ /// Retina and a 1x display re-measures the cell instead of leaving the grid
+ /// aligned to the other screen's pixels.
+ private var metricsScale: CGFloat = 2
- // 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])
+ /// The active theme's own background, or nil when it declares none — the
+ /// `*_transparent` themes and the curated `dark`. Nil is not a colour to
+ /// substitute but a decision: the ground stops being painted at all, the
+ /// view stops being opaque, and AppDelegate's NSVisualEffectView shows
+ /// through it. Read off pardes_theme_bg() once per pump, which is also
+ /// what makes a `Theme` command take hold without a relaunch.
+ private(set) var themeBG: UInt32? = pardesDefaultBG
- 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)
+ /// Adopt what the core is wearing. Returns whether anything moved, so the
+ /// host only reconfigures the window when it has to.
+ @discardableResult
+ func adoptThemeBG(_ encoded: UInt32) -> Bool {
+ let wanted: UInt32? =
+ encoded == UInt32(PARDES_COLOR_DEFAULT) ? nil : encoded & UInt32(PARDES_COLOR_RGB_MASK)
+ guard wanted != themeBG else { return false }
+ themeBG = wanted
+ needsDisplay = true
+ return true
+ }
+
+ // 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
+
+ init(fontSize size: CGFloat) {
+ // No window yet, so no backing scale to ask for: 2x is the guess every
+ // Mac shipped this decade would give, and viewDidChangeBackingProperties
+ // below re-measures the moment there is a real answer — including the
+ // 1x case, which a bare guess would otherwise leave wrong forever.
+ let built = Metrics(size: size, path: nil, scale: 2)
+ 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)
+ // Files dropped ON the grid. Finder and the Dock already reach the app
+ // through application(_:open:), but that path cannot say WHERE — and
+ // where is the whole difference between "a file opened somewhere" and
+ // acme's "a file opened next to the pane I pointed at".
+ registerForDraggedTypes([.fileURL])
+ // 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 scale = window?.backingScaleFactor ?? metricsScale
+ let next = Metrics(size: size, path: path, scale: scale)
+ // 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
+ metricsScale = scale
+ 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 }
+ // Opaque only while the theme brings its own background. A transparent
+ // theme has none, and an opaque view over a visual-effect backdrop is a
+ // grey rectangle where the blur should be.
+ override var isOpaque: Bool { themeBG != nil }
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 +508,13 @@ 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)
+ // The ground, from the core rather than from a constant agreed by hand.
+ // A transparent theme has none: CLEAR rather than fill, because AppKit
+ // does not blank a non-opaque view and last frame's pixels would
+ // otherwise pile up on themselves.
+ let ground = themeBG ?? pardesDefaultBG
+ let clearGround = themeBG == nil
+ if clearGround { ctx.clear(bounds) } else { fill(ctx, bounds, ground, 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
@@ -194,17 +532,23 @@ final class PardesView: NSView {
let base = row * cols
let y = CGFloat(row) * cellHeight
var start = 0
- var color = resolve(cells[base], block: blockY == row && blockX == 0).bg
+ var color = resolve(cells[base], block: blockY == row && blockX == 0,
+ ground: ground, clearGround: clearGround).bg
for col in 1...cols {
// A real color is 24 bits, so .max is a sentinel that cannot
// compare equal and therefore always flushes the last run.
let next: UInt32 = col == cols
? .max
- : resolve(cells[base + col], block: blockY == row && blockX == col).bg
+ : resolve(cells[base + col], block: blockY == row && blockX == col,
+ ground: ground, clearGround: clearGround).bg
if next == color { continue }
- fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
- width: CGFloat(col - start) * cellWidth, height: cellHeight),
- color, 1)
+ // bgClear runs are the ground showing through, and the ground is
+ // already clear — painting them would be painting the hole shut.
+ if color != bgClear {
+ fill(ctx, CGRect(x: CGFloat(start) * cellWidth, y: y,
+ width: CGFloat(col - start) * cellWidth, height: cellHeight),
+ color, 1)
+ }
start = col
color = next
}
@@ -215,96 +559,282 @@ 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 and the ascent is
+ // whole points (see Metrics), so every baseline is already on a pixel:
+ // letting CoreText place a glyph 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)
+ // Grayscale antialiasing, never LCD subpixel. Smoothing needs to know
+ // the colour behind the glyph, which over a transparent theme's
+ // backdrop it cannot — the result is coloured fringing that reads as
+ // blur. macOS has defaulted this off since 10.14, but the user can turn
+ // it back on globally and it is not their call to make for this grid.
+ ctx.setShouldSmoothFonts(false)
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()
+ // Pixel attachments over the grid: rasterized PDF pages, and image
+ // panes' own pixels. After the glyphs, the way the SDL shell draws them
+ // after its cells — a PDF pane's cells are blank, so the order only
+ // matters for the tag row an attachment must never reach, and the clip
+ // below is what keeps it off.
+ drawImages(ctx)
+
if bar {
let x = Int(pardes_cursor_x()), y = Int(pardes_cursor_y())
if x >= 0, y >= 0, x < cols, y < rows {
// gui.zig paints U+258F here. A rect is the same picture without
// asking the font for a glyph it may not carry.
- let fg = resolve(cells[y * cols + x], block: false).fg
+ let fg = resolve(cells[y * cols + x], block: false,
+ ground: themeBG ?? pardesDefaultBG, clearGround: 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)
+ }
+ }
+ }
+
+ /// What identifies a decoded raster: the pane's lifetime, the page, and the
+ /// generation MuPDF last rendered. Panning, zooming to fit and scrolling
+ /// deliberately move none of them, so the CGImage survives all three.
+ private struct ImageKey: Hashable {
+ let serial: UInt32
+ let page: UInt32
+ let revision: UInt32
+ }
+
+ /// Rasterized attachments, decoded once each. The bytes the core lends are
+ /// only valid until the next `pardes_frame`, so the CGImage owns a COPY —
+ /// which is exactly why the cache has to be keyed well enough that the copy
+ /// happens when the pixels change and never on an ordinary scroll.
+ private var imageCache: [ImageKey: CGImage] = [:]
+
+ private func drawImages(_ ctx: CGContext) {
+ let count = Int(pardes_frame_images())
+ guard count > 0, let list = pardes_frame_image_list() else {
+ // Nothing on screen owns pixels any more: the pages a closed pane
+ // rendered would otherwise sit in here for the rest of the session.
+ if !imageCache.isEmpty { imageCache.removeAll(keepingCapacity: true) }
+ return
+ }
+
+ // The core computed every rectangle in PHYSICAL pixels, because that is
+ // what pardes_resize handed it. The view draws in points.
+ let scale = max(1, metricsScale)
+ var live = Set<ImageKey>()
+ live.reserveCapacity(count)
+
+ ctx.setShouldAntialias(true)
+ for i in 0..<count {
+ let place = list[i]
+ let key = ImageKey(serial: place.serial, page: place.page, revision: place.revision)
+ live.insert(key)
+ guard let full = image(for: place, key: key) else { continue }
+ guard let crop = full.cropping(to: CGRect(
+ x: Int(place.src_x), y: Int(place.src_y),
+ width: Int(place.src_w), height: Int(place.src_h)))
+ else { continue }
+
+ // The body is the rectangle nothing may paint past. The core has
+ // already clipped the geometry to the viewport, but a tagline is
+ // not the viewport — a page one pixel too tall would sit on it.
+ let body = CGRect(
+ x: CGFloat(place.cell_x) * cellWidth, y: CGFloat(place.cell_y) * cellHeight,
+ width: CGFloat(place.cell_w) * cellWidth, height: CGFloat(place.cell_h) * cellHeight)
+ let dst = CGRect(
+ x: body.minX + CGFloat(place.dst_x) / scale,
+ y: body.minY + (CGFloat(place.dst_y) + CGFloat(place.offset_y)) / scale,
+ width: CGFloat(place.dst_w) / scale,
+ height: CGFloat(place.dst_h) / scale)
+
+ ctx.saveGState()
+ ctx.clip(to: body)
+ // isFlipped gives us a y-down CTM and CGImage draws +y up, so a
+ // plain ctx.draw would land every page upside down. Flip about the
+ // destination rather than about the view, so the arithmetic above
+ // stays in the grid's own coordinates.
+ ctx.translateBy(x: dst.minX, y: dst.maxY)
+ ctx.scaleBy(x: 1, y: -1)
+ // A page is resampled whenever fit or zoom disagrees with the
+ // raster MuPDF last produced; nearest-neighbour text is unreadable.
+ ctx.interpolationQuality = .high
+ ctx.draw(crop, in: CGRect(x: 0, y: 0, width: dst.width, height: dst.height))
+ ctx.restoreGState()
+ }
+
+ // Evict what this frame did not place. Scrolling a document past a page
+ // is the common case, and holding every page a session ever showed is
+ // how a PDF viewer ends up owning a gigabyte of decoded bitmaps.
+ if imageCache.count > live.count {
+ imageCache = imageCache.filter { live.contains($0.key) }
+ }
+ }
+
+ /// The decoded raster for one attachment, made once per generation.
+ private func image(for place: pardes_image_s, key: ImageKey) -> CGImage? {
+ if let cached = imageCache[key] { return cached }
+ let bytes = Int(place.iw) * Int(place.ih) * 4
+ guard bytes > 0, let rgba = place.rgba else { return nil }
+ // Copied, not referenced: the core lends these bytes until the next
+ // pardes_frame and this image outlives many of them.
+ guard let data = CFDataCreate(nil, rgba, bytes),
+ let provider = CGDataProvider(data: data)
+ else { return nil }
+ // Straight alpha, R,G,B,A in memory — the same bytes the SDL shell
+ // uploads as R8G8B8A8_UNORM and blends with ONE_MINUS_SRC_ALPHA.
+ let made = CGImage(
+ width: Int(place.iw), height: Int(place.ih),
+ bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(place.iw) * 4,
+ space: sRGB,
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue | CGBitmapInfo.byteOrder32Big.rawValue),
+ provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent)
+ if let made { imageCache[key] = made }
+ return made
+ }
+
+ /// 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 }
+ // clearGround: false — this pass only reads `fg`, and a glyph is
+ // never the hole in the ground.
+ let style = resolve(cell, block: blockCol == col,
+ ground: themeBG ?? pardesDefaultBG, clearGround: false)
+ 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()
}
- private func drawCell(
+ /// 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 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),
+ // Rounded like every other rule offset: a third of the ascent is a
+ // fraction, and a fractional one-pixel bar is a two-pixel smear.
+ fill(ctx, CGRect(x: x, y: baseline + (metrics.ascent * 0.3).rounded(), 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 +849,87 @@ 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()
+ }
+
+ /// The fingers came off the trackpad. A post-decode entry point of its own
+ /// so the e2e harness can throw the dial: NSEvent phases have no public
+ /// constructor, and a fling nothing can synthesize is a fling nothing can
+ /// assert.
+ func rotateEnd() {
+ pardes_rotate_end()
+ 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 +981,188 @@ 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 and the momentum, exactly as it owns the scroll accumulator.
+ ///
+ /// AppKit gives rotation no momentum phase of its own — `momentumPhase` is
+ /// scroll's alone — so the fling is measured from the release speed on the
+ /// Zig side rather than handed to us. All this has to get right is telling
+ /// it where the gesture starts and stops.
+ 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 —
+ // and stops a fling still coasting, because a finger back down is how
+ // a hand catches a dial.
+ if event.phase == .began { pardes_rotate(0) }
+ rotate(degrees: CGFloat(event.rotation))
+ // .cancelled too: a gesture the system took away should not fling.
+ if event.phase == .ended || event.phase == .cancelled { rotateEnd() }
+ }
+
+ /// 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))
}
- private func cell(for event: NSEvent) -> (col: UInt16, row: UInt16)? {
+ 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 +1170,68 @@ 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: - files dropped on the grid
+
+ /// A drop is a CLICK followed by `Look`, and that is the whole definition.
+ ///
+ /// The core has no notion of a drop and is not being given one: the pointer
+ /// lands where it landed, which focuses that pane exactly as a left click
+ /// there would, and then the ordinary `Look` builtin runs in it — so the
+ /// document opens beside the pane you pointed at rather than beside
+ /// whichever one happened to be focused. Drop on a tag and you clicked a
+ /// tag; there is no case to special-case, and nothing here the hand could
+ /// not have done itself.
+ override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
+ // AppKit reuses this answer for draggingUpdated when that is not
+ // implemented, so the cursor stays right for the whole drag.
+ droppedFiles(sender).isEmpty ? [] : .copy
+ }
+
+ override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
+ let paths = droppedFiles(sender)
+ guard !paths.isEmpty else { return false }
+ drop(paths, at: cellAt(convert(sender.draggingLocation, from: nil)))
+ return true
+ }
+
+ /// The drop, decoded: paths and a cell, nothing AppKit left in it.
+ ///
+ /// Split out for the reason every gesture here is — `NSDraggingInfo` is a
+ /// protocol with a dozen members and no public conformer, so a test that
+ /// had to build one would be testing its own stub. The decision lives one
+ /// call below the event, and `drop` in test/macos_e2e.swift drives exactly
+ /// this.
+ ///
+ /// A nil cell is a drop before the first frame, which has no grid to point
+ /// at: the files still open, they just open where focus already was.
+ func drop(_ paths: [String], at target: GridPoint?) {
+ if let target {
+ press(PARDES_MOUSE_LEFT, at: target)
+ release(PARDES_MOUSE_LEFT, at: target)
+ }
+ // Whole tail, unquoted: executeBuiltinLine takes everything after the
+ // first word as the argument, so a path with spaces in it needs no
+ // escaping and would in fact break under any.
+ for path in paths {
+ let line = "Look \(path)"
+ line.withCString { pardes_command($0, line.utf8.count) }
+ }
+ fed()
+ }
+
+ /// File paths on the drag pasteboard, in order. Empty for anything else,
+ /// which is also how draggingEntered decides whether to accept at all.
+ private func droppedFiles(_ sender: NSDraggingInfo) -> [String] {
+ let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
+ guard let urls = sender.draggingPasteboard.readObjects(
+ forClasses: [NSURL.self], options: options) as? [URL]
+ else { return [] }
+ return urls.map(\.path)
}
// MARK: - geometry
@@ -448,6 +1247,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
@@ -462,17 +1267,27 @@ final class PardesView: NSView {
// Dragging the window between a Retina display and a 1x one changes the
// backing scale without moving a single bound, so setFrameSize above never
- // fires and the physical cell metrics the core uses to place PDF pages stay
- // at the old scale forever. This is the only notification of it. (Ghostty
- // hooks the same one, and additionally re-fires from the window's
- // didChangeScreen notification, which AppKit does not always pair with it.)
+ // fires. This is the only notification of it. (Ghostty hooks the same one,
+ // and additionally re-fires from the window's didChangeScreen
+ // notification, which AppKit does not always pair with it.)
+ //
+ // TWO things depend on the scale: the physical cell metrics the core uses
+ // to place PDF pages, and the cell itself, which is snapped to whole
+ // DEVICE pixels (see Metrics) and is therefore aligned to the display it
+ // was measured on. Re-measuring reports the resize on its own, so the
+ // delegate call is the else-branch and not an extra one.
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
- delegate?.pardesViewDidResize(self)
+ if let scale = window?.backingScaleFactor, scale != metricsScale {
+ wear(size: fontSize, path: fontPath)
+ } else {
+ delegate?.pardesViewDidResize(self)
+ }
}
}
// ponytail: no NSTextInputClient, so dead keys and IME composition never reach
// the core — keyDown reads `characters` and that is the whole story. Adopting
// the protocol and routing through interpretKeyEvents is the upgrade when
-// someone needs to type Japanese.
+// 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
deleted file mode 100755
index 6734f8d3..00000000
--- a/src/macos/build-app.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/sh
-# Assemble pardes.app from libpardes.a and the Swift sources. Run it through
-# `zig build macos-app -Dplatform=macos`, or by hand with the install prefix as
-# $1 once `zig build -Dplatform=macos` has produced the library.
-#
-# There is no Xcode project on purpose. An .app is a directory with a plist and
-# a binary in it, swiftc ships with the Command Line Tools, and a hand-written
-# pbxproj would be a second build system to keep in step for no gain at this
-# stage. What Xcode buys — an xcframework of universal slices, codesigning,
-# notarization, a DMG — is distribution machinery; see docs/macos.md for the
-# upgrade path when that day comes.
-set -eu
-
-root=$(cd "$(dirname "$0")/../.." && pwd)
-out=${1:-"$root/zig-out"}
-app="$out/pardes.app"
-lib="$out/lib/libpardes.a"
-
-[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; }
-command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; }
-
-rm -rf "$app"
-mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
-cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist"
-
-# -import-objc-header rather than a module map: the header is consumed straight
-# from the source tree, so there is nothing to stage and nothing to keep in
-# sync. A module map is what an xcframework needs, and there isn't one.
-#
-# -lc++ because ghostty-vt pulls in simdutf and highway, which are C++. The Zig
-# side bundles compiler_rt/ubsan_rt into the archive (see build.zig), so the
-# C++ runtime is the only thing left for this link to supply.
-# -target is not optional. Without it swiftc uses the host triple, so
-# LC_BUILD_VERSION records whatever macOS built the thing and dyld refuses to
-# launch it on anything older — the Info.plist's LSMinimumSystemVersion is a
-# claim, not the enforcement. It is also what turns on the availability
-# diagnostics that catch a post-13 API before a user does.
-swiftc -O -target "$(uname -m)-apple-macos13.0" \
- -import-objc-header "$root/src/macos/pardes.h" \
- -o "$app/Contents/MacOS/pardes" \
- "$root"/src/macos/Sources/*.swift \
- "$lib" -lc++ \
- -framework AppKit -framework CoreText -framework CoreGraphics
-
-echo "built $app"
diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh
new file mode 100755
index 00000000..c0f9613c
--- /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 the app's own link uses, 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 the app link'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..2e775660
--- /dev/null
+++ b/src/macos/icon.swift
@@ -0,0 +1,307 @@
+// Draws pardes.app's icon at build time and hands the result to iconutil.
+//
+// The mark is GLENDA, the Plan 9 rabbit — pardes is an acme, and acme is
+// Plan 9's, so the bunny is the lineage stated in one shape. She is drawn out
+// of the terminal's own palette rather than traced from a bitmap: the ground
+// is defaultBG, the strip she sits under is the tag bar, and she herself is
+// defaultFG. That is also why this is generated instead of committed — 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.
+//
+// build.zig compiles this file alone into a cached binary and runs it with the
+// bundle's Resources directory 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 build.zig has this exit code waiting: a bundle whose icon did not draw
+/// should not have built.
+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
+
+// GLENDA, as ellipses. Four for the silhouette, filled as ONE path so the
+// overlaps vanish under nonzero winding and she is a single shape rather than
+// four stuck together, then two eyes punched back out in the ground colour.
+//
+// Ellipses and not a traced outline for the reason everything else here is a
+// fraction: the mark has to survive being twelve pixels across. An outlined
+// drawing at that size is a grey smudge with a lighter grey inside it, whereas
+// a silhouette is still a rabbit — the two ears are the whole recognition, and
+// they are the two shapes that reach furthest from the mass.
+let tagHeight: CGFloat = 0.165
+
+/// Her box: the body square under the tag bar, inset so the ears are not
+/// welded to the strip and the haunch is not welded to the bottom corners.
+let stageTop: CGFloat = 0.250
+let stageBottom: CGFloat = 0.950
+let stageInset: CGFloat = 0.135
+
+/// One ellipse of her, in fractions of that box: centre, radii, and a tilt in
+/// degrees about its own centre. Fractions rather than points because the same
+/// numbers have to describe the mark at 16 pixels and at 1024.
+struct Blob {
+ let cx: CGFloat
+ let cy: CGFloat
+ let rx: CGFloat
+ let ry: CGFloat
+ let tilt: CGFloat
+
+ init(_ cx: CGFloat, _ cy: CGFloat, _ rx: CGFloat, _ ry: CGFloat, tilt: CGFloat = 0) {
+ self.cx = cx
+ self.cy = cy
+ self.rx = rx
+ self.ry = ry
+ self.tilt = tilt
+ }
+
+ func path(in stage: CGRect) -> CGPath {
+ let box = CGRect(
+ x: -stage.width * rx, y: -stage.height * ry,
+ width: stage.width * rx * 2, height: stage.height * ry * 2)
+ var placement = CGAffineTransform(
+ translationX: stage.minX + stage.width * cx,
+ y: stage.minY + stage.height * cy
+ ).rotated(by: tilt * .pi / 180)
+ return CGPath(ellipseIn: box, transform: &placement)
+ }
+}
+
+// The ears overlap the head and the head overlaps the haunch on purpose: each
+// pair has to still intersect after rounding at 16 pixels, or she comes apart
+// into floating pieces at exactly the size nobody would look twice at.
+let silhouette: [Blob] = [
+ Blob(0.325, 0.150, 0.080, 0.200, tilt: -12), // left ear
+ Blob(0.675, 0.150, 0.080, 0.200, tilt: 12), // right ear
+ Blob(0.500, 0.490, 0.245, 0.212), // head
+ Blob(0.500, 0.785, 0.268, 0.215), // haunch
+]
+
+// Set wide and low in the head, which is the whole of her expression. Rounder
+// than a dot and smaller than the classic drawing's, because a big oval eye
+// closes up into a grey blur two sizes down.
+let eyes: [Blob] = [
+ Blob(0.393, 0.468, 0.056, 0.070),
+ Blob(0.607, 0.468, 0.056, 0.070),
+]
+
+/// Wider than it is tall, sitting just under the eyes: the one shape that says
+/// rabbit rather than cat. Punched in the ground colour like the eyes.
+let nose = Blob(0.500, 0.605, 0.045, 0.030)
+
+// ...and the block cursor, parked at the end of the tag bar. The palette's
+// last entry, and the only warm thing in the icon: pardes is still an acme,
+// and this is the two pixels that say so above her head.
+let cursorWidth: CGFloat = 0.072
+let cursorRightPad: CGFloat = 0.120
+
+/// 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
+}
+
+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))
+ let tagRect = CGRect(
+ x: body.minX, y: body.minY,
+ width: side, height: max(1, (side * tagHeight).rounded()))
+ ctx.fill(tagRect)
+
+ // The block cursor at the end of it. Inside the clip and inset from the
+ // corner so the rounding never clips a corner off the block itself.
+ ctx.setFillColor(cgColor(cursor))
+ ctx.fill(
+ CGRect(
+ x: (body.maxX - side * (cursorRightPad + cursorWidth)).rounded(),
+ y: (tagRect.minY + tagRect.height * 0.24).rounded(),
+ width: max(1, (side * cursorWidth).rounded()),
+ height: max(1, (tagRect.height * 0.52).rounded())))
+ ctx.restoreGState()
+
+ // Glenda. One fill for the whole silhouette so the four ellipses union
+ // instead of seaming, then the eyes and the nose over the top of her.
+ let stage = CGRect(
+ x: body.minX + side * stageInset,
+ y: body.minY + side * stageTop,
+ width: side * (1 - 2 * stageInset),
+ height: side * (stageBottom - stageTop))
+
+ ctx.setFillColor(cgColor(text))
+ for blob in silhouette { ctx.addPath(blob.path(in: stage)) }
+ ctx.fillPath(using: .winding)
+
+ // The ground colour rather than black: her eyes and nose are HOLES in her,
+ // and a hole darker than what is behind it reads as paint. One fill for all
+ // three, so they can never disagree about which colour a hole is.
+ ctx.setFillColor(cgColor(bodyTop))
+ for hole in eyes + [nose] { ctx.addPath(hole.path(in: stage)) }
+ ctx.fillPath(using: .winding)
+
+ 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),
+ ("icon_32x32.png", 32),
+ ("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..1591130e 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).
@@ -159,6 +174,16 @@ bool pardes_should_quit(void);
// A theme transition is mid-flight and wants ~60 Hz ticks until it settles.
bool pardes_animating(void);
+// What to paint where the grid does not: the window background behind the
+// titlebar and behind a live resize the view has not caught up with. The
+// theme's own background, so it changes the instant the theme does — chrome
+// (taglines, the move box) fades instead, which is why this is not it.
+//
+// PARDES_COLOR_DEFAULT means the theme declares NO background of its own. A
+// terminal wears whatever it was already wearing; a window has nothing to
+// wear, so the host should go transparent and show its own backdrop.
+uint32_t pardes_theme_bg(void);
+
// ---------------------------------------------------------------- events in
// `cp` is a codepoint or one of PARDES_KEY_*; `text`/`len` are the host's
@@ -169,14 +194,41 @@ 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 and to
+// stop a fling still coasting.
+void pardes_rotate(float degrees);
+
+// The fingers lifted. How fast they were moving decides everything: a slow
+// twist stops exactly where it was put, a flick keeps turning in proportion to
+// how hard it was thrown, and the two are the same curve — momentum ramps up
+// from zero rather than switching on at a threshold.
+//
+// A coast makes pardes_animating true and is spent by pardes_tick, so a host
+// that already re-pumps for theme transitions needs no new machinery; one that
+// never calls this simply has a dial with no momentum.
+void pardes_rotate_end(void);
+
+// 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.
@@ -192,11 +244,85 @@ const pardes_cell_s *pardes_frame_cells(void);
uint16_t pardes_frame_cols(void);
uint16_t pardes_frame_rows(void);
+// One rasterized pixel attachment: a PDF page, or an image pane's pixels.
+//
+// Geometry is in PHYSICAL PIXELS, the space pardes_resize's cell_w/cell_h put
+// the core in. `cell_x`/`cell_y` are the pane body's origin in CELLS and the
+// only thing to multiply out; `dst_*` is relative to that origin and `src_*`
+// is the crop of the raster to take. Both are already clipped to the viewport,
+// so a continuous-scroll page needs no overflow clip of its own — but the body
+// (`cell_w` x `cell_h` cells) is still the rectangle nothing may paint past.
+//
+// `serial`, `page` and `revision` together are the cache key: a host holds its
+// decoded texture while all three hold still, and panning, fit and scrolling
+// deliberately do not move them.
+typedef struct {
+ uint32_t serial;
+ uint32_t page;
+ uint32_t revision;
+ uint16_t cell_x;
+ uint16_t cell_y;
+ uint16_t cell_w;
+ uint16_t cell_h;
+ uint32_t dst_x;
+ uint32_t dst_y;
+ uint32_t dst_w;
+ uint32_t dst_h;
+ uint32_t src_x;
+ uint32_t src_y;
+ uint32_t src_w;
+ uint32_t src_h;
+ // subpixel vertical displacement a proportional wheel kept
+ float offset_y;
+ uint32_t iw;
+ uint32_t ih;
+ // iw * ih * 4 bytes, RGBA8, borrowed until the next pardes_frame
+ const uint8_t *rgba;
+} pardes_image_s;
+
+// This frame's attachments, in paint order. Ask after pardes_frame; both are
+// valid until the next one, exactly like the cell buffer.
+uint32_t pardes_frame_images(void);
+const pardes_image_s *pardes_frame_image_list(void);
+
+// The file behind the FOCUSED pane, or NULL when there is none: a terminal, an
+// output buffer, or nothing focused. PDFs and images count — they are real
+// paths, and a titlebar proxy icon is about the file, not about who may edit
+// it. Borrowed until the next call, like pardes_font_take.
+const char *pardes_active_path(void);
+
+// ...and whether that pane holds edits which are not on disk. Always false for
+// anything with no buffer to save, PDFs and images included.
+bool pardes_active_dirty(void);
+
// -1 when the cursor is hidden. `bar` asks for a thin insert-mode caret.
int32_t pardes_cursor_x(void);
int32_t pardes_cursor_y(void);
bool pardes_cursor_bar(void);
+// 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..4a067406 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -203,7 +203,9 @@ fn nativeMain(init: std.process.Init) !void {
// Native shells opt into the user config; the sans-IO core and web keep
// Options' null default. Read it before entering either frontend so every
// builtin has run before that frontend can render its first frame.
- opts.startup_config = @import("user_config.zig").load(init.io, arena, init.environ_map);
+ const found = @import("user_config.zig").load(init.io, arena, init.environ_map);
+ opts.startup_config = found.bytes;
+ opts.startup_config_path = found.path;
switch (pardes.platform) {
.tty => try @import("tty/tty.zig").run(init, opts),
.gui => try @import("gui/gui.zig").run(init, opts),
@@ -233,13 +235,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..16cea6a1 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;
@@ -193,6 +316,27 @@ pub fn sendLook(pid: libc.pid_t, path: []const u8, line: usize) bool {
return true;
}
+/// The three things ensureSocketDir has to know about a path, from whichever
+/// call the platform actually offers. Darwin has fstatat and no statx; on
+/// linux std.c.fstatat is `void` — glibc hides it behind a versioned symbol
+/// std cannot name — so linux asks statx for the same three fields. Both
+/// spellings refuse to follow a symlink, which is the point of asking.
+const DirFacts = struct { mode: u32, uid: libc.uid_t };
+
+fn statNoFollow(path: [:0]const u8) ?DirFacts {
+ if (comptime darwin) {
+ var st: libc.Stat = undefined;
+ if (libc.fstatat(libc.AT.FDCWD, path, &st, libc.AT.SYMLINK_NOFOLLOW) != 0) return null;
+ return .{ .mode = st.mode, .uid = st.uid };
+ } else {
+ const linux = std.os.linux;
+ var stx: linux.Statx = undefined;
+ const want: linux.STATX = .{ .TYPE = true, .MODE = true, .UID = true };
+ if (libc.statx(linux.AT.FDCWD, path, linux.AT.SYMLINK_NOFOLLOW, want, &stx) != 0) return null;
+ return .{ .mode = stx.mode, .uid = stx.uid };
+ }
+}
+
/// Create the socket directory if it is missing and refuse it unless it is a
/// directory WE own with nothing granted to group or other. A planted path is
/// the whole attack on a socket that runs commands, and $XDG_RUNTIME_DIR
@@ -202,7 +346,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 +355,14 @@ 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;
+ // A symlink where the directory should be is exactly the plant this
+ // guards against, so the stat above it does not follow one.
+ const st = statNoFollow(dir) orelse return false;
+ const IFMT: u32 = 0o170000;
+ const IFDIR: u32 = 0o040000;
+ if (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 +380,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 +396,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 +428,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 +444,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 +462,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 +490,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 +511,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 +557,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 the bundle carries the same build
+ // under the one name CFBundleExecutable can spell.
+ 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..8ff98845 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;
@@ -437,7 +438,6 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, text: []const u8) !void {
/// case. A second builtin would have been a second renderer over a superset of
/// these rows, and the two would have drifted the first time a column moved.
pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
- const pane = p.panes[id] orelse return error.MissingPane;
const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n";
const group_header = "pardes builtins under SPC";
var len: usize = if (prefix.len == 0)
@@ -451,7 +451,6 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
len += row.line.len + 1;
}
const content = try p.gpa.alloc(u8, len);
- errdefer p.gpa.free(content);
var at: usize = 0;
if (prefix.len == 0) {
@memcpy(content[0..full_header.len], full_header);
@@ -476,15 +475,51 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
at += 1;
}
std.debug.assert(at == content.len);
- // the buffer says what made it, so finding the open one is asking that and
- // not matching its name
+ // content is handed off unfreed on purpose: openRead adopts it or frees
+ // it, and nothing between the alloc above and this line can fail.
+ return openRead(p, id, .{ .cmd = .Help }, prefix, content);
+}
+
+/// The Config builtin: WHERE the startup config file is, as one line of text.
+///
+/// The PATH and not the file. `Look` on the line opens it when it exists, and
+/// when it does not the path is still the entire answer — "put your Theme and
+/// Font lines HERE" is the question this is asked, and a builtin that opened
+/// an empty buffer instead would have said nothing. The core never resolved
+/// it: the launcher did, before init (Options.startup_config_path), so this
+/// prints what was actually consulted rather than recomputing a guess that
+/// could differ from it.
+pub fn openConfig(p: *Pardes, id: usize) !void {
+ const content = if (p.opts.startup_config_path) |path|
+ try std.fmt.allocPrint(p.gpa, "{s}\n", .{path})
+ else
+ // the browser, and a native launch with no HOME to build one from
+ try p.gpa.dupe(u8, "no per-user config path\n");
+ return openRead(p, id, .{ .cmd = .Config }, "", content);
+}
+
+/// Open a buffer you READ, and go there: the shared tail of every builtin
+/// whose answer is a document rather than a list. Asking again REFRESHES the
+/// one already open instead of stacking a twin beside it — found by its
+/// ORIGIN, never by matching its name, for the reason the whole file exists.
+///
+/// The mirror of `openStepped`, and the difference is the two lines at the
+/// ends: focus comes HERE (you asked to read it) where a results buffer
+/// leaves you in the pane that asked, and n/N are not armed, because prose
+/// has nowhere to step to.
+///
+/// `content` is gpa-owned: adopted by the buffer, or freed here when there is
+/// nowhere to put it.
+fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void {
+ errdefer p.gpa.free(content);
+ const pane = p.panes[id] orelse return error.MissingPane;
for (p.panes, 0..) |slot, i| {
const hp = slot orelse continue;
const hf = if (hp.file) |*f| f else continue;
const ho = if (hf.output) |*o| o else continue;
- if (!std.meta.eql(ho.from, Origin{ .cmd = .Help })) continue;
+ if (!std.meta.eql(ho.from, from)) continue;
file_pane.setContent(p, hf, content);
- setArg(ho, prefix);
+ setArg(ho, arg);
hf.scroll = 0;
hp.cur_row = 0;
hp.msel.active = false;
@@ -493,7 +528,7 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
}
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
- const np = try open(p, free, dir, .{ .cmd = .Help }, prefix, content);
+ const np = try open(p, free, dir, from, arg, content);
p.placeDoc(id, free, np);
p.active = free;
}
diff --git a/src/pardes.zig b/src/pardes.zig
index e2ad426f..e9cc746d 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.
@@ -1742,6 +1748,43 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
+test "Config prints the resolved startup config path and refreshes one buffer" {
+ const path = "/home/pardes-test/.config/pardes";
+ // `startup_config` stays null: the file is MISSING and the path still
+ // resolves, which is the case this builtin exists to answer.
+ const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path });
+ defer p.deinit();
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ const opened = p.active;
+ const out = p.panes[opened].?.file.?;
+ try std.testing.expectEqualStrings(path ++ "\n", out.content);
+ try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path));
+ try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from);
+
+ // Asking again refreshes the buffer already open rather than stacking a
+ // byte-identical twin beside it — Help's rule, and for the same reason.
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ try std.testing.expectEqual(opened, p.active);
+ var buffers: usize = 0;
+ for (p.panes) |slot| {
+ const sp = slot orelse continue;
+ const f = sp.file orelse continue;
+ const o = f.output orelse continue;
+ if (std.meta.eql(o.from, output_pane.Origin{ .cmd = .Config })) buffers += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), buffers);
+}
+
+test "Config says so when there is no per-user config path" {
+ const p = try Pardes.init(std.testing.allocator, .{});
+ defer p.deinit();
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Config"));
+ const out = p.panes[p.active].?.file.?;
+ try std.testing.expect(std.mem.indexOf(u8, out.content, "no per-user config path") != null);
+}
+
test "runtime theme changes animate chrome and retarget without a jump" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -3230,6 +3273,22 @@ pub const File = struct {
/// goes through file_pane.setContent, which bumps this; a pipe completion
/// accepted against another revision would overwrite intervening work.
revision: u32 = 0,
+ /// The `revision` this buffer was last WRITTEN at. Equal means what is on
+ /// screen is what is on disk; anything else is unsaved work.
+ ///
+ /// Bookkeeping only — nothing in the core renders it, and no shell has to
+ /// read it. It exists because a windowed host has somewhere to PUT the
+ /// answer (macOS puts a dot in the close button, and the proxy icon it sits
+ /// beside is the same pane's path), and a shell cannot derive it: revision
+ /// counts edits, and only the save knows which edit was the last one
+ /// committed. Zero for a fresh buffer, which is why every construction site
+ /// gets clean-on-open from the default and none of them mention it.
+ ///
+ /// Marked at the moment Save is ASKED, not when the write lands: the
+ /// save_file effect carries no completion back, so this is as honest as the
+ /// rest of that path. A failed write reads as saved, exactly as the tagline
+ /// already does.
+ saved_revision: u32 = 0,
/// set = this is an OUTPUT buffer (acme's +Errors): a file pane with no
/// file behind it, showing text the core produced itself. It records the
/// COMMAND that opened it, and output_pane.zig's one table turns that into
@@ -4191,6 +4250,11 @@ pub const Options = struct {
/// deterministic; when present, each line is dispatched as a builtin
/// before init returns and therefore before any frontend can render.
startup_config: ?[]const u8 = null,
+ /// ...and WHERE that came from, which is a separate fact: the path
+ /// resolves even when the file does not exist, and that is precisely the
+ /// case the Config builtin is asked about. Null on the web and in every
+ /// core test, where there is no per-user config to name.
+ startup_config_path: ?[]const u8 = null,
image_allocator: ?std.mem.Allocator = null,
pdf_allocator: ?std.mem.Allocator = null,
tree_sitter_allocator: ?std.mem.Allocator = null,
@@ -4234,6 +4298,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,
@@ -4308,6 +4384,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,
@@ -4466,6 +4546,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;
}
@@ -11641,6 +11724,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
@@ -11808,6 +11892,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;
@@ -12282,6 +12370,7 @@ pub const Pardes = struct {
p.applyStartupConfig();
p.finishThemeInitialization();
p.sync();
+ _ = p.takeHaptic(); // see init: a restored session is not a gesture
return p;
}
@@ -12549,6 +12638,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/src/user_config.zig b/src/user_config.zig
index 455e8a3c..389188c1 100644
--- a/src/user_config.zig
+++ b/src/user_config.zig
@@ -31,17 +31,26 @@ pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8
return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" });
}
-/// Missing, unreadable, oversized, or otherwise unusable config is simply no
-/// config. The arena passed by main owns successful bytes for the process.
+/// The config file: WHERE it was looked for, and what was there. Missing,
+/// unreadable, oversized, or otherwise unusable config is simply no config —
+/// but the path resolves either way, because "nothing is there yet" is the
+/// answer the Config builtin exists to give and a null would erase it. The
+/// arena passed by the launcher owns both for the process.
+pub const Found = struct {
+ path: ?[]const u8 = null,
+ bytes: ?[]const u8 = null,
+};
+
pub fn load(
io: std.Io,
gpa: std.mem.Allocator,
env: *const std.process.Environ.Map,
-) ?[]u8 {
- const config_path = path(gpa, env) catch return null;
- defer if (config_path) |p| gpa.free(p);
- const p = config_path orelse return null;
- return std.Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(max_bytes)) catch null;
+) Found {
+ const config_path = (path(gpa, env) catch return .{}) orelse return .{};
+ return .{
+ .path = config_path,
+ .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null,
+ };
}
fn nonEmpty(value: ?[]const u8) ?[]const u8 {
@@ -88,10 +97,16 @@ test "config loader is silent when missing and returns exact file bytes" {
defer env.deinit();
try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]);
- try std.testing.expect(load(std.testing.io, std.testing.allocator, &env) == null);
+ const missing = load(std.testing.io, std.testing.allocator, &env);
+ defer std.testing.allocator.free(missing.path.?);
+ const expected = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" });
+ defer std.testing.allocator.free(expected);
+ try std.testing.expectEqualStrings(expected, missing.path.?);
+ try std.testing.expect(missing.bytes == null);
const source = "Theme dark\nUnknown command\nTheme acme\n";
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes", .data = source });
- const bytes = load(std.testing.io, std.testing.allocator, &env).?;
- defer std.testing.allocator.free(bytes);
- try std.testing.expectEqualStrings(source, bytes);
+ const found = load(std.testing.io, std.testing.allocator, &env);
+ defer std.testing.allocator.free(found.path.?);
+ defer std.testing.allocator.free(found.bytes.?);
+ try std.testing.expectEqualStrings(source, found.bytes.?);
}
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index 2e87d362..4767871f 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..1c971b3f
--- /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 680x396 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 850x495 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/drop.golden b/test/macos-snapshots/drop.golden
new file mode 100644
index 00000000..b4143983
--- /dev/null
+++ b/test/macos-snapshots/drop.golden
@@ -0,0 +1,94 @@
+== snap booted grid=100x30 cursor=4,3
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|$ /private/tmp/pardes-macos-e2e/drop/cwd New Del
+| $ printf 'first file\n' > one.txt; printf 'second file\n' > two.txt
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap dropped_on_shell grid=100x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/drop/cwd/one.txt S$ /private/tmp/pardes-macos-e2e/drop/cwd New Del
+| 1 first file $ printf 'first file\n' > one.txt; printf 'secon
+| 2 d file\n' > two.txt
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap dropped_on_document grid=100x30 cursor=7,17
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/drop/cwd/one.txt S$ /private/tmp/pardes-macos-e2e/drop/cwd New Del
+| 1 first file $ printf 'first file\n' > one.txt; printf 'secon
+| 2 d file\n' > two.txt
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /private/tmp/pardes-macos-e2e/drop/cwd/two.txt S
+| 1 second file
+| 2
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== draw drop 850x495 nonblank
diff --git a/test/macos-snapshots/drop.snap b/test/macos-snapshots/drop.snap
new file mode 100644
index 00000000..f427fe2a
--- /dev/null
+++ b/test/macos-snapshots/drop.snap
@@ -0,0 +1,37 @@
+# A file dropped ON the grid opens beside the pane it was dropped on.
+#
+# Finder and the Dock already reach the app through application(_:open:), but
+# that path cannot say WHERE — it opens next to whichever pane happened to have
+# focus. A drop knows where the hand was, and in acme that is the whole
+# difference: `Look` places the document relative to the pane it runs in.
+#
+# So a drop is defined as a CLICK followed by `Look`, and nothing more. The
+# click focuses the pane under the pointer exactly as a left click there would,
+# then the ordinary builtin runs in it. There is no drop concept in the core
+# and none was added — everything here the hand could have done itself.
+#
+# `drop` drives PardesView.drop, one call below performDragOperation, because
+# NSDraggingInfo is a protocol with no public conformer: a test that had to
+# build one would be testing its own stub. See the trackpad scripts for the
+# same reasoning about NSTouch and pressure stages.
+start 30 100
+wait 8000 $
+stable 700 20000
+text printf 'first file\n' > one.txt; printf 'second file\n' > two.txt
+key enter
+wait 8000 $
+stable 700 20000
+snap booted
+# Dropped on the shell: one.txt opens as a document beside it. Absolute,
+# because the core resolves a relative Look against the PANE's directory —
+# the same absolute path the AppKit side builds out of an NSURL.
+drop /private/tmp/pardes-macos-e2e/drop/cwd/one.txt 10 6
+stable 700 15000
+snap dropped_on_shell
+# ...and now the placement is the assertion. Dropping the SECOND file inside
+# the pane the first one opened puts it next to that pane, not next to the
+# shell — which is only true because the click went where the pointer was.
+drop /private/tmp/pardes-macos-e2e/drop/cwd/two.txt 10 20
+stable 700 15000
+snap dropped_on_document
+draw drop
diff --git a/test/macos-snapshots/font.golden b/test/macos-snapshots/font.golden
new file mode 100644
index 00000000..759e7738
--- /dev/null
+++ b/test/macos-snapshots/font.golden
@@ -0,0 +1,176 @@
+== 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=77x18 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=242x56 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..f0cc1eac
--- /dev/null
+++ b/test/macos-snapshots/rotate.golden
@@ -0,0 +1,280 @@
+== snap results grid=100x30 cursor=4,25
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+| zz
+| x MARK 14
+| zz
+| x MARK 15
+| zz
+| x MARK 16
+| zz
+| x MARK 17
+| zz
+| x MARK 18
+| zz
+| x MARK 19
+| zz
+| x MARK 20
+| zz
+| x MARK 21
+| zz
+| x MARK 22
+| zz
+| x MARK 23
+| zz
+| x MARK 24
+| zz
+|
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap forward grid=100x30 cursor=7,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap forward2 grid=100x30 cursor=7,6
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap back grid=100x30 cursor=7,6
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap forward_by_key grid=100x30 cursor=7,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap placed grid=100x30 cursor=7,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap placed_released grid=100x30 cursor=7,8
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 1 @p0:3:3-6 x MARK 01
+| 2 @p0:5:3-6 x MARK 02
+| 3 @p0:7:3-6 x MARK 03
+== snap thrown grid=100x30 cursor=7,16
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+|
+|
+| x MARK 01
+| zz
+| x MARK 02
+| zz
+| x MARK 03
+| zz
+| x MARK 04
+| zz
+| x MARK 05
+| zz
+| x MARK 06
+| zz
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 5 @p0:11:3-6 x MARK 05
+| 6 @p0:13:3-6 x MARK 06
+| 7 @p0:15:3-6 x MARK 07
+== snap thrown_coasted grid=100x30 cursor=7,22
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /private/tmp/pardes-macos-e2e/rotate/cwd New Del
+| x MARK 07
+| zz
+| x MARK 08
+| zz
+| x MARK 09
+| zz
+| x MARK 10
+| zz
+| x MARK 11
+| zz
+| x MARK 12
+| zz
+| x MARK 13
+| zz
+| x MARK 14
+| zz
+| x MARK 15
+| zz
+| x MARK 16
+| zz
+| x MARK 17
+| zz
+| x MARK 18
+| zz
+| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
+| 15 @p0:31:3-6 x MARK 15
+| 16 @p0:33:3-6 x MARK 16
+| 17 @p0:35:3-6 x MARK 17
+== draw rotate 850x495 nonblank
diff --git a/test/macos-snapshots/rotate.snap b/test/macos-snapshots/rotate.snap
new file mode 100644
index 00000000..1f2e4667
--- /dev/null
+++ b/test/macos-snapshots/rotate.snap
@@ -0,0 +1,107 @@
+# 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 10 degrees (rotation_notch_degrees in src/macos.zig) and the
+# remainder is banked, which makes the pairs below exact rather than
+# approximate: -12 spends one notch and banks -2.
+start 30 100
+wait 8000 New Newcol
+wait 8000 $
+stable 700 20000
+# Twenty-four hits, not three. A coast is worth asserting only on a list long
+# enough to coast ALONG: `n` at the last match has nowhere to go, so on a
+# three-hit list the hardest possible flick and no flick at all produce the
+# same screen — which is a golden that would have passed before momentum
+# existed. printf reuses its format once per argument.
+text printf 'x MA''RK %s\nzz\n' 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
+key enter
+wait 10000 MARK 24
+stable 700 10000
+key c-b
+stable 600 8000
+# `/` types the pattern into the tag; Enter searches the pane's scrollback and
+# writes the 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 -12
+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 -12
+stable 700 15000
+snap forward2
+# A new gesture, because the dial banks its remainder exactly like the scroll
+# accumulator does: two 12-degree notches leave -4 behind, so reversing INSIDE
+# the same twist would have to spend that first and 12 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 12
+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
+# ---- momentum ----
+#
+# The dial coasts in proportion to how fast it was RELEASED, ramping up from
+# zero at the floor rather than switching on at it. Both halves of that are
+# here, and the gap argument is what makes them different: it is a real sleep
+# before the event, so libpardes measures a real speed off its clock.
+#
+# PLACED. Four degrees every 100 ms is 40 deg/s, under the 70 deg/s floor, so
+# lifting the fingers changes nothing at all — the twist itself has moved one
+# notch and that is where it stops. This is the case that has to hold: a slow
+# deliberate turn that kept sliding afterwards would be unusable.
+rotate 0
+rotate -4 100
+rotate -4 100
+rotate -4 100
+stable 700 15000
+snap placed
+rotate_end
+stable 700 15000
+# Byte-identical to `placed`, which is the whole assertion.
+snap placed_released
+# THROWN. The same twist delivered in 5 ms slices is hundreds of degrees a
+# second, so the release keeps the list walking after the fingers are gone.
+#
+# `snap` and then `rotate_end` with NO `stable` between them, which is not
+# impatience: a release is only a throw if it arrives while the hand is still
+# moving, and a 700 ms wait here would be a hand that stopped — which is
+# exactly what the PLACED case above already proves. The snapshot is a frame
+# read, far inside the 90 ms that separates the two.
+rotate 0
+rotate -12 5
+rotate -12 5
+rotate -12 5
+snap thrown
+rotate_end
+stable 700 15000
+# ...and this one must NOT match `thrown`: the fling moved on its own, after
+# the gesture was over. That difference IS the feature.
+snap thrown_coasted
+draw rotate
diff --git a/test/macos-snapshots/trackpad.golden b/test/macos-snapshots/trackpad.golden
new file mode 100644
index 00000000..5a82ea85
--- /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 850x495 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..42bc8229
--- /dev/null
+++ b/test/macos_e2e.swift
@@ -0,0 +1,1016 @@
+// 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()
+
+ // `rotate <degrees> [gap_ms]`. The optional gap is a real sleep BEFORE
+ // the event, and it is the only way a script can say how FAST the dial
+ // is being turned: libpardes measures the release speed off the
+ // monotonic clock between events, so back-to-back script calls read as
+ // an impossibly hard flick. Pace them and a slow twist is genuinely
+ // slow — which is the half of the momentum contract worth asserting,
+ // because "no fling" is not something a fling test can show.
+ case "rotate":
+ let view = try live()
+ let degrees = try double(args, 0, "rotate degrees")
+ if args.count > 1 {
+ Thread.sleep(forTimeInterval: try double(args, 1, "rotate gap_ms") / 1000)
+ }
+ view.rotate(degrees: CGFloat(degrees))
+ pump()
+
+ // The fingers coming off. Its own command because the fling is decided
+ // by the RELEASE SPEED, and a script that could only turn the dial
+ // could never throw it — NSEvent phases have no public constructor, so
+ // this entry point is the only way the momentum path is reachable at
+ // all. The coast is then spent by the pump, at one fixed step per tick.
+ case "rotate_end":
+ let view = try live()
+ view.rotateEnd()
+ pump()
+
+ // `drop <path> <col> <row>`: a file dropped ON the grid at that cell.
+ // Same reason as the two above — `NSDraggingInfo` is a protocol with a
+ // dozen members and no public conformer, so the entry point below the
+ // event is the only honest way in. What it must prove is the WHERE: a
+ // drop is a click plus Look, so the document lands beside the pane
+ // pointed at rather than beside whichever one had focus.
+ case "drop":
+ let view = try live()
+ let path = try token(args, 0, "drop path")
+ let cell = try gridPoint(args, 1, "drop")
+ view.drop([path], at: cell)
+ 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/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 7e4b3366..a54b8281 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -100,26 +100,26 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
-| 19 SPC l D WsDiagnostics
-| 20 SPC l S WsSymbols
-| 21 SPC l a CodeAction
-| 22 SPC l d Diagnostics
-| 23 SPC l h SelectRefs
-| 24 SPC l i Lspinfo
-| 25 SPC l k Hover
-| 26 SPC l r Rename
-| 27 SPC l s Symbols
-| 28 SPC l w Lspwhy
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
+| 19 SPC k Kill topbar
+| 20 SPC l D WsDiagnostics
+| 21 SPC l S WsSymbols
+| 22 SPC l a CodeAction
+| 23 SPC l d Diagnostics
+| 24 SPC l h SelectRefs
+| 25 SPC l i Lspinfo
+| 26 SPC l k Hover
+| 27 SPC l r Rename
+| 28 SPC l s Symbols
== snap index-tail grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -153,34 +153,34 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 25 SPC l k Hover
-| 26 SPC l r Rename
-| 27 SPC l s Symbols
-| 28 SPC l w Lspwhy
-| 29 SPC s d Dump topbar
-| 30 SPC s r Restore
-| 31 SPC t a Ascii
-| 32 SPC t b Tagbottom
-| 33 SPC t c Colors
-| 34 SPC t d Debug topbar
-| 35 SPC t i PdfTint
-| 36 SPC t l Palette
-| 37 SPC t n NextColor topbar
-| 38 SPC t p Petscii
-| 39 SPC t r Crt
-| 40 SPC t s PdfSections
-| 41 SPC t t ThemeSel
-| 42 SPC t w Wrap
-| 43 SPC t z PdfFit
-| 44 SPC w h Left C-w h, C-w left
-| 45 SPC w j Down C-w j, C-w down
-| 46 SPC w k Up C-w k, C-w up
-| 47 SPC w l Right C-w l, C-w right
-| 48 Look enter, right-click
-| 49 Exec tab, middle-click
-| 50 Theme
-| 51 Shell
-| 52
+| 26 SPC l k Hover
+| 27 SPC l r Rename
+| 28 SPC l s Symbols
+| 29 SPC l w Lspwhy
+| 30 SPC s d Dump topbar
+| 31 SPC s r Restore
+| 32 SPC t a Ascii
+| 33 SPC t b Tagbottom
+| 34 SPC t c Colors
+| 35 SPC t d Debug topbar
+| 36 SPC t i PdfTint
+| 37 SPC t l Palette
+| 38 SPC t n NextColor topbar
+| 39 SPC t p Petscii
+| 40 SPC t r Crt
+| 41 SPC t s PdfSections
+| 42 SPC t t ThemeSel
+| 43 SPC t w Wrap
+| 44 SPC t z PdfFit
+| 45 SPC w h Left C-w h, C-w left
+| 46 SPC w j Down C-w j, C-w down
+| 47 SPC w k Up C-w k, C-w up
+| 48 SPC w l Right C-w l, C-w right
+| 49 Look enter, right-click
+| 50 Exec tab, middle-click
+| 51 Theme
+| 52 Shell
+| 53
== snap not-a-picker grid=120x60 cursor=7,58
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -214,34 +214,34 @@
|
|
| /tmp/pardes-snap/builtins/cwd/+Help New Del
-| 25 SPC l k Hover
-| 26 SPC l r Rename
-| 27 SPC l s Symbols
-| 28 SPC l w Lspwhy
-| 29 SPC s d Dump topbar
-| 30 SPC s r Restore
-| 31 SPC t a Ascii
-| 32 SPC t b Tagbottom
-| 33 SPC t c Colors
-| 34 SPC t d Debug topbar
-| 35 SPC t i PdfTint
-| 36 SPC t l Palette
-| 37 SPC t n NextColor topbar
-| 38 SPC t p Petscii
-| 39 SPC t r Crt
-| 40 SPC t s PdfSections
-| 41 SPC t t ThemeSel
-| 42 SPC t w Wrap
-| 43 SPC t z PdfFit
-| 44 SPC w h Left C-w h, C-w left
-| 45 SPC w j Down C-w j, C-w down
-| 46 SPC w k Up C-w k, C-w up
-| 47 SPC w l Right C-w l, C-w right
-| 48 Look enter, right-click
-| 49 Exec tab, middle-click
-| 50 Theme
-| 51 Shell
-| 52
+| 26 SPC l k Hover
+| 27 SPC l r Rename
+| 28 SPC l s Symbols
+| 29 SPC l w Lspwhy
+| 30 SPC s d Dump topbar
+| 31 SPC s r Restore
+| 32 SPC t a Ascii
+| 33 SPC t b Tagbottom
+| 34 SPC t c Colors
+| 35 SPC t d Debug topbar
+| 36 SPC t i PdfTint
+| 37 SPC t l Palette
+| 38 SPC t n NextColor topbar
+| 39 SPC t p Petscii
+| 40 SPC t r Crt
+| 41 SPC t s PdfSections
+| 42 SPC t t ThemeSel
+| 43 SPC t w Wrap
+| 44 SPC t z PdfFit
+| 45 SPC w h Left C-w h, C-w left
+| 46 SPC w j Down C-w j, C-w down
+| 47 SPC w k Up C-w k, C-w up
+| 48 SPC w l Right C-w l, C-w right
+| 49 Look enter, right-click
+| 50 Exec tab, middle-click
+| 51 Theme
+| 52 Shell
+| 53
== snap window-group grid=120x60 cursor=7,32
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/builtins/cwd/notes.txt Save New Del
@@ -344,23 +344,23 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
-| 19 SPC l D WsDiagnostics
-| 20 SPC l S WsSymbols
-| 21 SPC l a CodeAction
-| 22 SPC l d Diagnostics
-| 23 SPC l h SelectRefs
-| 24 SPC l i Lspinfo
-| 25 SPC l k Hover
-| 26 SPC l r Rename
-| 27 SPC l s Symbols
-| 28 SPC l w Lspwhy
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
+| 19 SPC k Kill topbar
+| 20 SPC l D WsDiagnostics
+| 21 SPC l S WsSymbols
+| 22 SPC l a CodeAction
+| 23 SPC l d Diagnostics
+| 24 SPC l h SelectRefs
+| 25 SPC l i Lspinfo
+| 26 SPC l k Hover
+| 27 SPC l r Rename
+| 28 SPC l s Symbols
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index 93e33710..f6b5723b 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -49,12 +49,16 @@ stable 700 15000
snap window-group
# back to the whole index, and a NAME in it is live text like anywhere else:
# middle-click `Tutor` and the tutor opens. That is the picking a stepping
-# picker would have done, minus running the thirty-nine rows you passed.
+# picker would have done, minus running the rows you passed.
+#
+# The one place in this suite a builtin cannot be added for free: the click is
+# a SCREEN coordinate, so every row inserted above `SPC h t` in the listing
+# moves Tutor down one and this number with it.
key space ?
wait 10000 SPC c n
stable 700 15000
-press middle 20 45
-release middle 20 45
+press middle 20 46
+release middle 20 46
wait 10000 PARDES TUTOR
stable 700 15000
snap tutor-from-index
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 74719f13..d58e8d6c 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -193,16 +193,16 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
== snap help-group grid=100x40 cursor=7,22
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del
@@ -357,16 +357,16 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
== snap help-toggles grid=100x40 cursor=7,22
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/leader/cwd/cmds.txt Save New Del
diff --git a/test/snapshots/ttyhelp.golden b/test/snapshots/ttyhelp.golden
index 69b5961a..a580263b 100644
--- a/test/snapshots/ttyhelp.golden
+++ b/test/snapshots/ttyhelp.golden
@@ -42,24 +42,24 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
-| 19 SPC l D WsDiagnostics
-| 20 SPC l S WsSymbols
-| 21 SPC l a CodeAction
-| 22 SPC l d Diagnostics
-| 23 SPC l h SelectRefs
-| 24 SPC l i Lspinfo
-| 25 SPC l k Hover
-| 26 SPC l r Rename
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
+| 19 SPC k Kill topbar
+| 20 SPC l D WsDiagnostics
+| 21 SPC l S WsSymbols
+| 22 SPC l a CodeAction
+| 23 SPC l d Diagnostics
+| 24 SPC l h SelectRefs
+| 25 SPC l i Lspinfo
+| 26 SPC l k Hover
== snap leaderworks grid=100x30 cursor=7,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
|$ /tmp/pardes-snap/ttyhelp/cwd New Del
@@ -73,21 +73,21 @@
| 6 SPC c d Delcol
| 7 SPC c n Newcol topbar
| 8 SPC d Del
-| 9 SPC f f Find topbar
-| 10 SPC f g Grep topbar
-| 11 SPC f n New topbar
-| 12 SPC f s Save
-| 13 SPC h t Tutor topbar
-| 14 SPC j i Forward C-i
-| 15 SPC j j Last
-| 16 SPC j l Jumplist
-| 17 SPC j o Back C-o
-| 18 SPC k Kill topbar
-| 19 SPC l D WsDiagnostics
-| 20 SPC l S WsSymbols
-| 21 SPC l a CodeAction
-| 22 SPC l d Diagnostics
-| 23 SPC l h SelectRefs
-| 24 SPC l i Lspinfo
-| 25 SPC l k Hover
-| 26 SPC l r Rename
+| 9 SPC f c Config
+| 10 SPC f f Find topbar
+| 11 SPC f g Grep topbar
+| 12 SPC f n New topbar
+| 13 SPC f s Save
+| 14 SPC h t Tutor topbar
+| 15 SPC j i Forward C-i
+| 16 SPC j j Last
+| 17 SPC j l Jumplist
+| 18 SPC j o Back C-o
+| 19 SPC k Kill topbar
+| 20 SPC l D WsDiagnostics
+| 21 SPC l S WsSymbols
+| 22 SPC l a CodeAction
+| 23 SPC l d Diagnostics
+| 24 SPC l h SelectRefs
+| 25 SPC l i Lspinfo
+| 26 SPC l k Hover
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),