summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig424
1 files changed, 401 insertions, 23 deletions
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);
+}