diff options
31 files changed, 2274 insertions, 647 deletions
diff --git a/.DS_Store b/.DS_Store Binary files differnew file mode 100644 index 00000000..6cf40f95 --- /dev/null +++ b/.DS_Store @@ -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 |
