diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 17:23:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | a797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch) | |
| tree | 45db37a3d190425b3ca2453d46c57d2af27cfc93 /src | |
| parent | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff) | |
| download | pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip | |
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a
relaunch, and builds into something you can hand to someone.
- Pixel attachments. Surface.images was dropped on the floor here, so a PDF
pane showed nothing at all: native_images is now set, pardes_image_s carries
the geometry the core already clipped, and PardesView keeps one CGImage per
(serial, page, revision) so scrolling costs a draw and not a decode. Image
panes get real pixels instead of the petscii fallback.
- Themes take hold live. pardes_tick never advanced the chrome animation, so
every tagline kept the previous theme's colours until the next launch and
the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires
the hand-agreed #121212 and drives the window background and the titlebar
appearance; a theme with no background of its own now gets a transparent
window over an NSVisualEffectView.
- The cell snaps to whole DEVICE pixels rather than whole points. Monaco
advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than
the face was drawn for.
- The dial is one notch per 10 degrees instead of 20, and a release keeps
turning in proportion to how hard it was thrown -- ramping up from zero at
the floor, so a slow twist coasts not a little but not at all.
- A file dropped on the grid is a click plus Look, so it opens beside the pane
it was dropped on. No drop concept was added to the core.
- The titlebar follows the focused pane: proxy icon, filename, and the dirty
dot. File.saved_revision is the watermark that last one needed.
- Config (SPC f c) prints the resolved startup config path.
- build.zig assembles, signs and packages the bundle itself; build-app.sh is
gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and
the icon is Glenda.
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 24 | ||||
| -rw-r--r-- | src/config.zig | 4 | ||||
| -rw-r--r-- | src/macos.zig | 424 | ||||
| -rw-r--r-- | src/macos/Info.plist | 12 | ||||
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 133 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 377 | ||||
| -rwxr-xr-x | src/macos/build-app.sh | 94 | ||||
| -rwxr-xr-x | src/macos/build-e2e.sh | 8 | ||||
| -rw-r--r-- | src/macos/icon.swift | 183 | ||||
| -rw-r--r-- | src/macos/pardes.h | 74 | ||||
| -rw-r--r-- | src/main.zig | 4 | ||||
| -rw-r--r-- | src/nested.zig | 4 | ||||
| -rw-r--r-- | src/output_pane.zig | 48 | ||||
| -rw-r--r-- | src/pardes.zig | 58 | ||||
| -rw-r--r-- | src/user_config.zig | 37 |
15 files changed, 1212 insertions, 272 deletions
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.?); } |
