summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.DS_Storebin0 -> 10244 bytes
-rw-r--r--build.zig187
-rw-r--r--docs/config.md6
-rw-r--r--docs/macos.md321
-rw-r--r--src/builtins.zig24
-rw-r--r--src/config.zig4
-rw-r--r--src/macos.zig424
-rw-r--r--src/macos/Info.plist12
-rw-r--r--src/macos/Sources/AppDelegate.swift133
-rw-r--r--src/macos/Sources/PardesView.swift377
-rwxr-xr-xsrc/macos/build-app.sh94
-rwxr-xr-xsrc/macos/build-e2e.sh8
-rw-r--r--src/macos/icon.swift183
-rw-r--r--src/macos/pardes.h74
-rw-r--r--src/main.zig4
-rw-r--r--src/nested.zig4
-rw-r--r--src/output_pane.zig48
-rw-r--r--src/pardes.zig58
-rw-r--r--src/user_config.zig37
-rw-r--r--test/macos-snapshots/boot.golden4
-rw-r--r--test/macos-snapshots/drop.golden94
-rw-r--r--test/macos-snapshots/drop.snap37
-rw-r--r--test/macos-snapshots/font.golden11
-rw-r--r--test/macos-snapshots/rotate.golden362
-rw-r--r--test/macos-snapshots/rotate.snap66
-rw-r--r--test/macos-snapshots/trackpad.golden2
-rw-r--r--test/macos_e2e.swift33
-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
31 files changed, 2274 insertions, 647 deletions
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..6cf40f95
--- /dev/null
+++ b/.DS_Store
Binary files differ
diff --git a/build.zig b/build.zig
index 8f654ff1..2d2b7840 100644
--- a/build.zig
+++ b/build.zig
@@ -8,10 +8,10 @@ pub const Platform = enum { tty, gui, web, macos };
/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things
/// have to agree about it or the bundle is a lie: the target this build gives
-/// the static library, the `-target` build-app.sh passes swiftc (which is what
-/// writes LC_BUILD_VERSION, the thing dyld actually enforces), and the plist's
-/// LSMinimumSystemVersion. The script gets it as an argument and sets the last
-/// two from it, so there is one string and no drift.
+/// 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
@@ -77,6 +77,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.
@@ -661,11 +665,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,
@@ -696,43 +701,157 @@ pub fn build(b: *std.Build) void {
// libtool and ranlib are Apple's, so this needs a Darwin host as well
// as a Darwin target; a cross-build installs the plain archive, which
// is all the Linux dev loop ever links (nothing).
- if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin())
- b.getInstallStep().dependOn(&b.addInstallLibFile(fatArchive(b, lib), "libpardes.a").step)
+ 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.installArtifact(lib);
+ &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, ""));
- // The deployment target travels as an argument rather than being
- // written twice: see macos_min_version.
- app.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }));
- // ...and so does the optimize mode, so swiftc compiles the shell the
- // same way zig compiled the core. Hardcoding -O there meant the default
- // `zig build macos-app` shipped an optimized shell around a Debug core,
- // which costs 5x a frame and looks like a slow backend rather than a
- // Debug build. See the note in build-app.sh.
- app.addArg(@tagName(optimize));
- app.has_side_effects = true; // writes a bundle outside the cache
- app.step.dependOn(b.getInstallStep());
- // The library the script links has to BE a Mach-O one. Cross-building
- // the ABI for another host is supported and tested (see the Linux dev
- // loop in docs/macos.md); assembling a bundle out of it is not.
+ // ---- 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)");
- if (target.result.os.tag.isDarwin())
- app_step.dependOn(&app.step)
- else
- app_step.dependOn(&b.addFail("macos-app needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step);
+ 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 as the app, because it must be the same link.
+ // 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(b.getInstallStep());
+ 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
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 b1666134..7f695000 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -197,15 +197,104 @@ haptic feedback" on in System Settings, which nothing in this process can read.
Twisting two fingers is a dial, and a dial over a list of search hits is `n`.
`pardes_rotate` takes raw degrees and libpardes quantizes them, one search step
-per 20°, keeping the remainder — the same accumulate-and-spend shape as
+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° notches,
-twelve search steps — the granularity is right, and the reason a twist can look
-like it does nothing is that `n` has nowhere to step until a search is armed.
+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
@@ -291,8 +380,8 @@ Identity gained a third sibling. `bin/pardes` and
directory at all, so the comparison is made at the *install prefix* — the
directory holding the `.app`, or the parent of a `bin` — and the bundle's name
can never carry the `-os-arch` tail the installed binary does, because
-`CFBundleExecutable` is a fixed string. `zig build macos-app` then `pardes
-src/foo.zig` inside the app's own shell opens a pane in the app.
+`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
@@ -335,6 +424,110 @@ 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.
@@ -366,7 +559,7 @@ pinned low), and that default is not survivable here: swiftc links this archive,
so a Steam Deck build hands ld64 ELF objects inside a GNU archive and the app
link dies with `archive member '/SYM64/' not a mach-o file`. On a Mac the
default becomes the host arch at `macos_min_version`, which is the same triple
-`build-app.sh` gives swiftc, so the two halves of the app cannot disagree about
+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.
@@ -383,20 +576,43 @@ through `ranlib` first, because ld64 otherwise refuses zig's layout outright
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: it copies
-`Contents/Info.plist` and stamps `LSMinimumSystemVersion` from the version
-build.zig passed it, compiles `src/macos/icon.swift` and runs it to emit
-`Contents/Resources/pardes.icns`, and links the app:
+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 -target "$(uname -m)-apple-macos$minver" \
+swiftc -O -target arm64-apple-macos13.0 \
-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++ \
+ -o <cache>/pardes \
+ src/macos/Sources/{main,AppDelegate,PardesView}.swift \
+ <cache>/libpardes.a -lc++ \
-framework AppKit -framework CoreText -framework CoreGraphics
```
@@ -413,19 +629,32 @@ a claim, not the enforcement. The deployment version is spelled once, as
`macos_min_version` in `build.zig`, and reaches the `-target`, the plist and the
library's own target from there.
-The icon is generated rather than committed: no binary blob in the tree, and its
-palette stays in step with the one `PardesView` draws with, because both read
-the same constants.
+## 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.
## Testing
@@ -477,8 +706,12 @@ zig build macos-e2e -Dplatform=macos -- test/macos-snapshots/rotate.snap
`main.swift`, into a second binary — test scaffolding does not ship inside the
product. Scripts are `test/macos-snapshots/*.snap` and speak the tty suite's
vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`) plus what only
-exists here: `fingers <n>`, `force`, `rotate <degrees>`, `scroll`, `haptic
-<none|exec|look>` and `draw`. Output is byte-identical in shape to
+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.
@@ -500,7 +733,7 @@ config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and
`TMPDIR` inside the per-script world so that `New`'s document has a reproducible
directory — its six mkstemp characters are masked on capture.
-**The app itself.** `zig build macos-app -Dplatform=macos && open zig-out/pardes.app`.
+**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.
@@ -517,12 +750,12 @@ and its phases over 60 frames of a shell pouring out four thousand lines.
| **whole `draw`** | **4516 us** | **879 us** |
The finding is the first row, and it is not about drawing at all. `swiftc` was
-hardcoded to `-O` in `build-app.sh` while the Zig core followed `-Doptimize`,
-so the ordinary `zig build macos-app` shipped an optimized shell wrapped around
-a Debug core — and that reads as "the mac backend is slow" rather than "you
-built Debug". The mode now travels as the script's third argument, both halves
-are compiled the same way, and a Debug bundle says so on the way out. Build one
-you intend to *use* with `-Doptimize=ReleaseFast`.
+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
@@ -555,11 +788,6 @@ for exactly that reason.
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.
- **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
@@ -578,7 +806,8 @@ for exactly that reason.
tool, which is the first thing in this backend that would actually require
Xcode — hence not done. The file itself is verifiably correct: `iconutil -c
iconset` round-trips all ten, and the tag bar is `#3465A4` at every size.
-- **Distribution.** No codesigning, no notarization, no universal binary, no
- bundled fonts, no localization. The app has an icon, a plist that says what it
- opens, and a deployment target it actually enforces; everything past that is
- Gatekeeper's business and starts with `lipo`.
+- **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/src/builtins.zig b/src/builtins.zig
index f747765d..f207ed9f 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -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 38af0a67..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",
@@ -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/macos.zig b/src/macos.zig
index d3f9577c..ef68d7e7 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -29,6 +29,10 @@ 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;
};
@@ -80,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
@@ -239,6 +281,11 @@ 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
@@ -255,6 +302,16 @@ const State = struct {
/// 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
@@ -317,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();
@@ -329,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.
@@ -420,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();
@@ -437,9 +504,36 @@ 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.
@@ -499,9 +593,32 @@ export fn pardes_tick() bool {
}
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;
}
@@ -595,11 +712,78 @@ export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) vo
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.
+ // 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
@@ -634,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;
@@ -671,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;
@@ -736,6 +990,45 @@ export fn pardes_font_take() ?[*:0]const u8 {
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
@@ -987,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;
@@ -1001,11 +1291,51 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 {
return whole;
}
-/// One search step per this many degrees of twist. A trackpad rotation runs
-/// tens of degrees before it feels deliberate, and every notch here is a jump
-/// to another match — coarse on purpose, so a thumb resettling cannot walk the
-/// cursor across the file.
-const rotation_notch_degrees: f32 = 20;
+/// 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
@@ -1052,17 +1382,23 @@ test "pardes.h declares every export the way it is defined" {
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" {
@@ -1076,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);
@@ -1167,18 +1509,18 @@ test "trackpad rotation spends whole search steps and keeps the remainder" {
var lag: f32 = 0;
// A twist under one notch moves nothing; crossing it moves exactly one,
// and the overshoot is credited to the next.
- try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 15));
- try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 10));
- try expectEqual(@as(f32, 5), lag);
+ 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, -25));
+ 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, 9), takeRotationNotches(&lag, 180));
+ 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.
@@ -1188,3 +1530,39 @@ test "trackpad rotation spends whole search steps and keeps the remainder" {
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 665d54ca..3d526242 100644
--- a/src/macos/Info.plist
+++ b/src/macos/Info.plist
@@ -17,16 +17,16 @@
<key>CFBundleVersion</key>
<string>1</string>
<!-- No extension: Launch Services appends .icns and looks in
- Contents/Resources, where build-app.sh emits pardes.icns. Without this
- key the Dock shows the blank generic app even though the icon is
- sitting right there in the bundle. -->
+ 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-app.sh overwrites this from macos_min_version in build.zig, which
- is the single source of truth; the key must exist for PlistBuddy's Set
- to have something to set. -->
+ <!-- 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>
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift
index f5ca4080..cda35ff0 100644
--- a/src/macos/Sources/AppDelegate.swift
+++ b/src/macos/Sources/AppDelegate.swift
@@ -15,6 +15,9 @@ import AppKit
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
@@ -61,7 +64,32 @@ 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.
@@ -89,16 +117,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// no grid behind it. macOS offers tabs on any titled resizable window
// unless told otherwise.
window.tabbingMode = .disallowed
- // The same constant PardesView paints as defaultBG. Two places name it
- // because two different things draw: the view fills its own bounds, and
- // AppKit fills the titlebar and every pixel of a live resize the view
- // has not caught up with yet — without this that gap flashes white on
- // every drag. The day the core exposes its theme background over the
- // ABI, both should read it instead of agreeing by hand.
- window.backgroundColor = NSColor(srgbRed: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255, alpha: 1)
- // A dark window with a light-mode titlebar reads as a bug. This also
- // gets the traffic-light glyphs and the resize cursor right.
- window.appearance = NSAppearance(named: .darkAqua)
+ // 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
@@ -396,6 +422,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
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) {
pump()
}
@@ -419,6 +525,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate {
// 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
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index c7152bd3..3d841a60 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -83,6 +83,12 @@ enum Trackpad {
let pardesDefaultFG: UInt32 = 0xCC_CC_CC
let pardesDefaultBG: UInt32 = 0x12_12_12
+/// 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
// a CTFont instead of being bridged to NSFont on the way in.
@@ -127,20 +133,31 @@ private enum Face: Int, CaseIterable {
}
}
-/// `block` means the filled cursor sits on this cell.
+/// 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
- ? (pardesDefaultBG, pardesDefaultFG, 1, false)
- : (pardesDefaultFG, pardesDefaultBG, 1, false)
+ ? (ground, pardesDefaultFG, 1, false)
+ : (pardesDefaultFG, clearGround ? bgClear : ground, 1, false)
}
+ let bgDefault = cell.bg == UInt32(PARDES_COLOR_DEFAULT)
var fg = decodeColor(cell.fg, pardesDefaultFG)
- var bg = decodeColor(cell.bg, pardesDefaultBG)
+ 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.
@@ -154,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)
}
@@ -190,8 +210,16 @@ private struct Metrics {
/// per face here and never looked up again.
let asciiGlyphs: [[CGGlyph]]
- init(size: CGFloat, path: String?) {
+ /// 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`.
@@ -200,19 +228,32 @@ private struct Metrics {
}
let faces = [face, variant(.traitBold), variant(.traitItalic), variant([.traitBold, .traitItalic])]
- // Whole points, rounded UP. A fractional cell width puts every column
- // boundary on a fraction of a pixel, and with antialiasing off — which
- // the background pass needs, or touching fills seam — the rounding
- // wobbles by a pixel from column to column. On a screen made of tag
- // bars and selections that stripe is visible. Rounding up rather than
- // to nearest because down can clip a glyph, and a slightly airy grid is
- // not a bug.
+ // 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
- ascent = CTFontGetAscent(face)
- cellWidth = max(1, advance(face, 0x4D).rounded(.up))
- cellHeight = max(1, (CTFontGetAscent(face) + CTFontGetDescent(face) + CTFontGetLeading(face)).rounded(.up))
- ruleThickness = max(1, CTFontGetUnderlineThickness(face))
- underlineOffset = CTFontGetUnderlinePosition(face)
+ // 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)
@@ -307,6 +348,31 @@ final class PardesView: NSView {
private var reportedCols: UInt16 = 0
private var reportedRows: UInt16 = 0
+ /// 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
+
+ /// 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
+
+ /// 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
@@ -329,7 +395,11 @@ final class PardesView: NSView {
private var restingFingers: Int = 0
init(fontSize size: CGFloat) {
- let built = Metrics(size: size, path: nil)
+ // 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
@@ -340,6 +410,11 @@ final class PardesView: NSView {
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.
@@ -371,11 +446,13 @@ final class PardesView: NSView {
/// system face, and asking for a font that is not wearable leaves the
/// screen exactly as it was rather than blank.
private func wear(size: CGFloat, path: String?) {
- let next = Metrics(size: size, path: path)
+ 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)
@@ -405,7 +482,10 @@ final class PardesView: NSView {
// 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
@@ -428,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, pardesDefaultBG, 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
@@ -446,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
}
@@ -467,11 +559,19 @@ final class PardesView: NSView {
// line mirrored. Un-flip once for the whole glyph pass and convert each
// baseline into it rather than fighting the text matrix per cell.
ctx.setShouldAntialias(true)
- // Every glyph sits at an exact multiple of cellWidth, so letting
- // CoreText place it on a subpixel would blur a grid that is aligned by
- // construction. Quantizing keeps the rasterizer's own cache hitting.
+ // 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)
@@ -484,12 +584,20 @@ final class PardesView: NSView {
}
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).rounded(.up)), height: cellHeight), fg, 1)
@@ -497,6 +605,104 @@ final class PardesView: NSView {
}
}
+ /// 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
@@ -526,7 +732,10 @@ final class PardesView: NSView {
for col in 0..<cols {
let cell = cells[base + col]
if cell.flags & UInt8(PARDES_CELL_DEFAULT) != 0 { continue }
- let style = resolve(cell, block: blockCol == col)
+ // 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
@@ -621,7 +830,9 @@ final class PardesView: NSView {
}
}
if cell.attrs & UInt16(PARDES_ATTR_STRIKETHROUGH) != 0 {
- fill(ctx, CGRect(x: x, y: baseline + metrics.ascent * 0.3, width: cellWidth, height: metrics.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)
}
}
@@ -704,6 +915,15 @@ final class PardesView: NSView {
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) {
@@ -903,13 +1123,22 @@ final class PardesView: NSView {
/// Two fingers twisted on the trackpad are the search-step keys: clockwise
/// walks forward through the matches, counterclockwise back. It is a dial,
/// and n/N is what a dial over a list of hits means. libpardes owns the
- /// quantizing, exactly as it does for scroll.
+ /// 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.
+ // 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
@@ -946,6 +1175,65 @@ final class PardesView: NSView {
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
override func updateTrackingAreas() {
@@ -979,13 +1267,22 @@ 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)
+ }
}
}
diff --git a/src/macos/build-app.sh b/src/macos/build-app.sh
deleted file mode 100755
index a54e76b8..00000000
--- a/src/macos/build-app.sh
+++ /dev/null
@@ -1,94 +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, the deployment target as $2 and the Zig optimize mode as $3 once
-# `zig build -Dplatform=macos` has produced the library.
-#
-# There is no Xcode project on purpose. An .app is a directory with a plist and
-# a binary in it, swiftc ships with the Command Line Tools, and a hand-written
-# 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"}
-# Keep in step with macos_min_version in build.zig, which passes it in. The
-# default is only for a by-hand run.
-minver=${2:-13.0}
-# Both swiftc invocations below take the same triple; the note above the app
-# link is why -target is not optional for either of them.
-target="$(uname -m)-apple-macos$minver"
-app="$out/pardes.app"
-lib="$out/lib/libpardes.a"
-# The two halves of this program are compiled by two compilers, and before this
-# only one of them was told anything: swiftc was hardcoded to -O while the Zig
-# core followed -Doptimize, so the ordinary `zig build macos-app` produced an
-# optimized shell around a DEBUG core. It does not read as "I built Debug", it
-# reads as "the mac backend is slow" — measured on this machine, one frame at
-# 190x56 cost 4.5 ms with a Debug core and 0.88 ms with a ReleaseFast one, and
-# 4.1 ms of that 4.5 was pardes_frame alone. So the mode travels, both halves
-# agree, and a slow bundle says why.
-zigmode=${3:-Debug}
-case $zigmode in
-Debug) swiftmode=-Onone ;;
-ReleaseSmall) swiftmode=-Osize ;;
-*) swiftmode=-O ;;
-esac
-
-[ -f "$lib" ] || { echo "missing $lib — run: zig build -Dplatform=macos" >&2; exit 1; }
-command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command Line Tools)" >&2; exit 1; }
-
-rm -rf "$app"
-mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
-cp "$root/src/macos/Info.plist" "$app/Contents/Info.plist"
-# One version, three consumers: the plist's claim is set from the same string
-# the link below enforces, so a bumped deployment target cannot leave a stale
-# LSMinimumSystemVersion behind.
-/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $minver" "$app/Contents/Info.plist" >/dev/null
-
-# The icon is generated rather than committed. The mark is drawn out of the
-# same palette PardesView.swift renders cells with — #121212 body, the tag bar
-# and the block cursor straight from ansi16 — so a colour that moves there
-# moves here on the next build, instead of a binary blob sitting in the tree
-# quietly disagreeing with the app it ships in. Info.plist's CFBundleIconFile
-# names the pardes.icns this drops into Resources; without both halves the Dock
-# falls back to the generic blank page.
-#
-# The trap covers the compiled generator; the generator clears its own scratch
-# iconset. set -eu means a failure here takes the whole build down, which is
-# the point: a bundle that ships a blank icon should not have built.
-icontmp=$(mktemp -d)
-trap 'rm -rf "$icontmp"' EXIT
-swiftc -O -target "$target" -o "$icontmp/pardes-icon" "$root/src/macos/icon.swift"
-"$icontmp/pardes-icon" "$app/Contents/Resources"
-
-# -import-objc-header rather than a module map: the header is consumed straight
-# from the source tree, so there is nothing to stage and nothing to keep in
-# 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 "$swiftmode" -target "$target" \
- -import-objc-header "$root/src/macos/pardes.h" \
- -o "$app/Contents/MacOS/pardes" \
- "$root"/src/macos/Sources/*.swift \
- "$lib" -lc++ \
- -framework AppKit -framework CoreText -framework CoreGraphics
-
-# An .app whose mtime never moves is an .app Launch Services keeps serving from
-# its cache, icon and plist and all.
-touch "$app"
-
-if [ "$zigmode" = Debug ]; then
- echo "built $app (Debug core — rebuild with -Doptimize=ReleaseFast to use it)"
-else
- echo "built $app"
-fi
diff --git a/src/macos/build-e2e.sh b/src/macos/build-e2e.sh
index 7e0d2233..c0f9613c 100755
--- a/src/macos/build-e2e.sh
+++ b/src/macos/build-e2e.sh
@@ -21,9 +21,9 @@ set -eu
root=$(cd "$(dirname "$0")/../.." && pwd)
out=${1:-"$root/zig-out"}
# Keep in step with macos_min_version in build.zig, which passes it in. The
-# default is only for a by-hand run. Same string as build-app.sh, and for the
-# same reason: -target is what decides LC_BUILD_VERSION and turns on the
-# availability diagnostics.
+# 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"
@@ -33,7 +33,7 @@ command -v swiftc >/dev/null || { echo "swiftc not found (needs macOS + Command
mkdir -p "$out/bin"
-# -import-objc-header and -lc++ are build-app.sh's, unchanged, and have to stay
+# -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.
#
diff --git a/src/macos/icon.swift b/src/macos/icon.swift
index b5de1838..2e775660 100644
--- a/src/macos/icon.swift
+++ b/src/macos/icon.swift
@@ -1,15 +1,17 @@
// Draws pardes.app's icon at build time and hands the result to iconutil.
//
-// It is generated instead of committed because the mark IS the palette: the
-// body is defaultBG, the tag bar and the block cursor are entries out of the
-// same ansi16 table src/macos/Sources/PardesView.swift paints cells with. A
+// 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.
//
-// src/macos/build-app.sh compiles this file alone into a temporary binary and
-// runs it with the bundle's Contents/Resources as argv[1]; Info.plist's
-// CFBundleIconFile names the pardes.icns that comes out. Compiled alone is also
-// what makes top-level code legal here: one file, one module, its own binary.
+// 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
@@ -23,7 +25,8 @@ import UniformTypeIdentifiers
/// Every failure path lands here. A build that ships the generic blank-page
/// icon looks like an app nobody finished, so half-drawn is worse than absent
-/// and the script has `set -e` waiting for the exit code.
+/// 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)
@@ -60,30 +63,80 @@ let cursor = RGB(0xFC_E9_4F) // ansi16[11], bright yellow
let squareFraction: CGFloat = 0.8047
let cornerFraction: CGFloat = 0.2237
-// The composition, as fractions of the body square. It has to survive being
-// twelve pixels across, so it is four bands and nothing else: the full-width
-// tag bar, a dim line for the pane that does not hold the keyboard, a gutter
-// wide enough to read as the boundary between panes, then the focused pane's
-// line with the block cursor parked at its end. Three shapes under the bar is
-// the entire budget; a fourth is grey mush at 16 pixels.
+// 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
-let sidePad: CGFloat = 0.145
-let lineHeight: CGFloat = 0.085
-let dimLineTop: CGFloat = 0.355
-let dimLineWidth: CGFloat = 0.505
-let dimLineAlpha: CGFloat = 0.52
-let liveLineTop: CGFloat = 0.645
-let liveLineWidth: CGFloat = 0.300
-let liveLineAlpha: CGFloat = 0.72
-let cursorLeft: CGFloat = 0.515
-// A cell, not a square: a block cursor covers a whole character box, so it is
-// wider than the gap before it and well over twice the height of the ink it
-// sits on. The exact numbers are also the ones that survive rounding — 0.205
-// of a twelve-pixel body spans 2.46 pixels, which lands on two rows wherever
-// the top edge falls, whereas anything near 0.155 collapses to one row for
-// half the possible alignments and the block turns into a dash.
-let cursorWidth: CGFloat = 0.130
-let cursorHeight: CGFloat = 0.205
+
+/// 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,
@@ -100,18 +153,6 @@ func cgColor(_ rgb: RGB, alpha: CGFloat = 1) -> CGColor {
return color
}
-/// Whole device pixels, or the 16pt render turns each one-pixel bar into two
-/// rows of half-lit grey and the whole mark goes soft. Rounding the edges
-/// rather than the size is what keeps the gaps between bands even.
-func snap(_ rect: CGRect) -> CGRect {
- let left = rect.minX.rounded()
- let top = rect.minY.rounded()
- return CGRect(
- x: left, y: top,
- width: max(1, rect.maxX.rounded() - left),
- height: max(1, rect.maxY.rounded() - top))
-}
-
func renderIcon(pixels: Int) -> CGImage {
guard
let ctx = CGContext(
@@ -157,40 +198,40 @@ func renderIcon(pixels: Int) -> CGImage {
// 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.minX, y: body.minY,
- width: side, height: max(1, (side * tagHeight).rounded())))
+ 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()
- // Two panes. The gap between the lines is deliberately the widest space in
- // the icon: it is the pane boundary, and proximity is the only way to say
- // so without spending the fourth shape on a divider.
- let leftEdge = body.minX + side * sidePad
- ctx.setFillColor(cgColor(text, alpha: dimLineAlpha))
- ctx.fill(
- snap(
- CGRect(
- x: leftEdge, y: body.minY + side * dimLineTop,
- width: side * dimLineWidth, height: side * lineHeight)))
+ // 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, alpha: liveLineAlpha))
- ctx.fill(
- snap(
- CGRect(
- x: leftEdge, y: body.minY + side * liveLineTop,
- width: side * liveLineWidth, height: side * lineHeight)))
+ ctx.setFillColor(cgColor(text))
+ for blob in silhouette { ctx.addPath(blob.path(in: stage)) }
+ ctx.fillPath(using: .winding)
- // Centred on the line it follows and taller than it, the way a block cursor
- // covers a whole cell rather than the ink in it. Full-strength yellow
- // against the blue is what tells you at a glance which pane has focus.
- let cursorTop = liveLineTop + (lineHeight - cursorHeight) / 2
- ctx.setFillColor(cgColor(cursor))
- ctx.fill(
- snap(
- CGRect(
- x: body.minX + side * cursorLeft, y: body.minY + side * cursorTop,
- width: side * cursorWidth, height: side * cursorHeight)))
+ // 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
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index 62ddbae5..1591130e 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -174,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
@@ -200,9 +210,20 @@ void pardes_scroll(float delta_rows, float delta_cols, uint16_t col,
// 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.
+// 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
@@ -223,6 +244,57 @@ 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);
diff --git a/src/main.zig b/src/main.zig
index 96aedc0a..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),
diff --git a/src/nested.zig b/src/nested.zig
index a8fd021d..0db38ad9 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -552,8 +552,8 @@ test "the app bundle is the same build as the binary installed beside it" {
"/work/zig-out/bin/pardes-gui",
));
// The case this machine actually produces: `zig build` installs the tty
- // binary under its os-arch tail, and build-app.sh copies the same build
- // into a bundle where it can only be called `pardes`.
+ // 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",
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 593af3d3..8ff98845 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -438,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)
@@ -452,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);
@@ -477,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;
@@ -494,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 ec938a6d..d07fc1f1 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1748,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();
@@ -3215,6 +3252,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
@@ -4162,6 +4215,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,
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/macos-snapshots/boot.golden b/test/macos-snapshots/boot.golden
index 28e5a1de..1c971b3f 100644
--- a/test/macos-snapshots/boot.golden
+++ b/test/macos-snapshots/boot.golden
@@ -23,7 +23,7 @@
|
|
|
-== draw boot 720x408 nonblank
+== 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
@@ -55,4 +55,4 @@
|
|
|
-== draw resized 900x510 nonblank
+== draw resized 850x495 nonblank
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
index e61abecd..759e7738 100644
--- a/test/macos-snapshots/font.golden
+++ b/test/macos-snapshots/font.golden
@@ -73,9 +73,9 @@
|
|
|
-== snap bigger grid=81x19 cursor=4,2
+== 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
+|$ /private/tmp/pardes-macos-e2e/font/cwd New Del
| $
|
|
@@ -92,7 +92,6 @@
|
|
|
-|
== 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
@@ -118,9 +117,9 @@
|
|
|
-== snap floor grid=225x58 cursor=4,2
+== 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
+|$ /private/tmp/pardes-macos-e2e/font/cwd New Del
| $
|
|
@@ -175,5 +174,3 @@
|
|
|
-|
-|
diff --git a/test/macos-snapshots/rotate.golden b/test/macos-snapshots/rotate.golden
index 54f0d217..f0cc1eac 100644
--- a/test/macos-snapshots/rotate.golden
+++ b/test/macos-snapshots/rotate.golden
@@ -1,156 +1,280 @@
-== snap results grid=100x30 cursor=4,8
+== 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
-|
-| x MARK a
| zz
-| x MARK b
+| 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
-| x MARK c
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
|
| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
-| 1 @p0:2:3-6 x MARK a
-| 2 @p0:4:3-6 x MARK b
-| 3 @p0:6:3-6 x MARK c
-== snap forward grid=100x30 cursor=7,3
+| 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 a
+|
+| x MARK 01
| zz
-| x MARK b
+| 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
-| x MARK c
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
-| 1 @p0:2:3-6 x MARK a
-| 2 @p0:4:3-6 x MARK b
-| 3 @p0:6:3-6 x MARK c
-== snap forward2 grid=100x30 cursor=7,5
+| 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 a
+|
+| x MARK 01
| zz
-| x MARK b
+| 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
-| x MARK c
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
-| 1 @p0:2:3-6 x MARK a
-| 2 @p0:4:3-6 x MARK b
-| 3 @p0:6:3-6 x MARK c
-== snap back grid=100x30 cursor=7,5
+| 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 a
+|
+| x MARK 01
| zz
-| x MARK b
+| x MARK 02
| zz
-| x MARK c
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
+| 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
-| 1 @p0:2:3-6 x MARK a
-| 2 @p0:4:3-6 x MARK b
-| 3 @p0:6:3-6 x MARK c
-== snap forward_by_key grid=100x30 cursor=7,7
+| 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 a
+| x MARK 07
| zz
-| x MARK b
+| 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
-| x MARK c
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
| /private/tmp/pardes-macos-e2e/rotate/cwd/+Search New Del
-| 1 @p0:2:3-6 x MARK a
-| 2 @p0:4:3-6 x MARK b
-| 3 @p0:6:3-6 x MARK c
-== draw rotate 900x510 nonblank
+| 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
index 17b239f4..1f2e4667 100644
--- a/test/macos-snapshots/rotate.snap
+++ b/test/macos-snapshots/rotate.snap
@@ -5,21 +5,26 @@
# kind of thing a unit test on the accumulator cannot catch and a golden can:
# what the screen does is the assertion.
#
-# One notch is 20 degrees (rotation_notch_degrees in src/macos.zig) and the
-# remainder is banked, which makes the pair below exact rather than approximate:
-# -25 spends one notch and banks -5, then +25 lands on 20 and spends one back.
+# 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
-text printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n'
+# 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 c
+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 three hits into a +Search buffer, which is what n/N walk.
+# writes the hits into a +Search buffer, which is what n/N walk.
text /
stable 400 5000
text MARK
@@ -32,25 +37,25 @@ snap results
# twist cannot make the first degree of this one jump a match.
rotate 0
# Clockwise: one notch of `n`, forward onto the first hit and selecting it.
-rotate -25
+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 -25
+rotate -12
stable 700 15000
snap forward2
# A new gesture, because the dial banks its remainder exactly like the scroll
-# accumulator does: two 25-degree notches leave -10 behind, so reversing INSIDE
-# the same twist would have to spend that first and 25 back would move nothing.
+# 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 25
+rotate 12
stable 700 15000
snap back
# And the other half of the contract: a twist IS the keystroke, so typing the
@@ -60,4 +65,43 @@ snap back
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
index 82edf68b..5a82ea85 100644
--- a/test/macos-snapshots/trackpad.golden
+++ b/test/macos-snapshots/trackpad.golden
@@ -308,4 +308,4 @@
| 1 @p0:2:3-6 x MARK a
| 2 @p0:4:3-6 x MARK b
| 3 @p0:6:3-6 x MARK c
-== draw trackpad 900x510 nonblank
+== draw trackpad 850x495 nonblank
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index ba0c48f0..42bc8229 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -609,12 +609,45 @@ private final class Driver: PardesViewDelegate {
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")
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