From a797a1ab2f648e773f7a1b28d12bf9441b9f13f4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 10 Aug 2026 17:23:18 -0300 Subject: 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. --- src/macos.zig | 424 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 401 insertions(+), 23 deletions(-) (limited to 'src/macos.zig') 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); +} -- cgit v1.3