summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 20:31:56 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitc5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0 (patch)
tree6cd19cfb7fbd076433fbb965e8a52ac1800949fb /src/animation.zig
parent242467eb9f066f80164d534be55c2c4ebc9b5604 (diff)
downloadpardes-c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0.tar.gz
pardes-c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0.zip
Motion flavours differ by design, and drive notice drops and pane moves
The flavours looked alike because Lift, the only motion that read them, moves a shadow a few pixels: overshoot and wind-up were sub-pixel. By design now: crisp leaves at full speed and never passes its mark; smooth (the default) eases in and out in twice crisp's time, a slide arriving in 208 ms (inside §8.1's 220); bouncy passes its mark by ~30% and settles twice; playful winds up, passes by ~40%, and stretches and squashes, text included (the user's choice). Small motions are exaggerated by the flavour's gain. Motion gains launch, gain, span and norm, and ease(u)/speed(u) for moves of fixed length. The notice drop follows ease() over its fall (alpha never behind its position) and stays inside its pane's body past its row. Panel tracks carry the flavour in Track.motion (the old padding byte; macOS header name follows); opening and moving slides, zooms and rises follow it over span x their frames; landed, a pane is exactly its target; closing keeps its own exit. A pane past its mark never takes a neighbour's click; a rising one stays in its box. Tests: each flavour's lift trajectory distinct; flavour character; exact landing bit for bit; a stretched pane's drawn cells map back; the overshoot hit rule; the vertical clip at peak; legacy tests pinned to crisp. GUI golden 18-mid-transition changes (the retuned smooth slide at frame 6). Shared files: src/animation.zig, src/Presentation.zig, src/Messages.zig, src/macos.zig, src/config.zig, src/pardes.zig tests, src/ninep/screen.zig test, docs/effects.md, docs/config.md.
Diffstat (limited to 'src/animation.zig')
-rw-r--r--src/animation.zig147
1 files changed, 130 insertions, 17 deletions
diff --git a/src/animation.zig b/src/animation.zig
index b02a242b..9b43644e 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -129,7 +129,9 @@ pub const Track = extern struct {
pane: u8 = 0,
phase: Phase = .moving,
effect: Transition = .off,
- _padding: u8 = 0,
+ /// The Motion flavour a pane's move follows, plus one; 0 is the
+ /// effect's own easing (Presentation.stamp).
+ motion: u8 = 0,
frame: u16 = 0,
frame_count: u16 = 0,
from: Box = .{},
@@ -148,11 +150,47 @@ pub const Track = extern struct {
// accelerates down out of the fixed clip.
if (track.phase == .closing and track.effect == .vertical)
return progressEased(.in_cubic, track.frames(), track.frame);
+ if (track.flavour()) |motion|
+ return motion.ease(track.progress());
return progressEased(track.effect.easing(), track.frames(), track.frame);
}
+ /// The flavour an opening or moving pane follows; a closing one keeps
+ /// its effect's own exit.
+ fn flavour(track: Track) ?Motion {
+ if (track.motion == 0 or track.phase == .closing) return null;
+ const which = std.enums.fromInt(Motion.Flavour, track.motion - 1) orelse return null;
+ return Motion.of(which);
+ }
+
+ fn progress(track: Track) f32 {
+ if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1;
+ return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1));
+ }
+
pub fn presented(track: Track) Box {
- return lerpBox(track.from, track.to, track.amount());
+ // A flavour's wind-up goes back past where the move began.
+ const t = track.amount();
+ // Landed, it is exactly where it goes: no stretch or float residue.
+ if (track.flavour() != null and track.progress() >= 1) return track.to;
+ var box = if (t < 0) lerpBox(track.to, track.from, 1 - t) else lerpBox(track.from, track.to, t);
+ // Squash and stretch: along its path a moving pane lengthens with
+ // its speed and, landing past its mark and coming back, squashes
+ // (the user's choice: its text with it; a pointer inverts the same
+ // box, so a click lands on the glyph it is drawn as).
+ const motion = track.flavour() orelse return box;
+ if (motion.stretch == 0) return box;
+ const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1);
+ if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) {
+ const w = box.w * factor;
+ box.x -= (w - box.w) / 2;
+ box.w = w;
+ } else {
+ const h = box.h * factor;
+ box.y -= (h - box.h) / 2;
+ box.h = h;
+ }
+ return box;
}
pub fn visualBox(track: Track) Box {
@@ -613,20 +651,51 @@ pub const Motion = struct {
/// Arcs: how far a two-dimensional path bows, as a fraction of its
/// length (the cursor's glide).
arc: f32 = 0,
+ /// Exaggeration: how far a small motion goes, as a multiple of its
+ /// plain distance (a lift of 1 rises `gain`), so a flavour's character
+ /// shows on a motion only a few pixels big.
+ gain: f32 = 1,
+ /// Timing: a move from rest leaves at this fraction of full speed (1:
+ /// out of the gate at once and easing in, 0: slow in and slow out).
+ launch: f32 = 0,
+ /// For a motion of fixed length (a pane's move, a notice's drop): its
+ /// length as a multiple of the effect's own, and the spring's frequency
+ /// over that length (`ease`).
+ span: f32 = 1,
+ norm: f32 = 10,
pub fn of(flavour: Flavour) Motion {
return switch (flavour) {
.off => .{ .instant = true },
// Productivity: quick and exact, nothing past its mark.
- .crisp => .{ .omega = 44 },
- // A longer, softer ease in and out, still without a bounce.
- .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 },
- // Settles past and back, and stretches with its speed.
- .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 },
- // A cartoon's: winds up, overshoots, stretches, follows through.
- .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 },
+ .crisp => .{ .omega = 60, .launch = 1, .span = 0.6, .norm = 10 },
+ // Longer and softer, easing in and out, still without a bounce.
+ // The default: within §8.1's 220 ms for an arrival.
+ .smooth => .{ .omega = 34, .follow = 0.85, .arc = 0.04, .span = 1.1, .norm = 6 },
+ // Clearly past its mark and back, once or twice, stretching
+ // with its speed.
+ .bouncy => .{ .omega = 24, .zeta = 0.35, .stretch = 0.15, .follow = 0.8, .arc = 0.08, .gain = 1.25, .span = 1.4, .norm = 13 },
+ // A cartoon's: winds up, flies past, squashes as it lands,
+ // its followers trailing.
+ .playful => .{ .omega = 18, .zeta = 0.28, .anticipate = 0.15, .stretch = 0.35, .follow = 0.55, .arc = 0.15, .gain = 1.4, .span = 1.7, .norm = 18 },
};
}
+
+ /// A move of fixed length from 0 to 1, `u` of the way through it: the
+ /// flavour's spring (wind-up, overshoot and all), landing exactly on 1
+ /// at its end. `instant` is there at once.
+ pub fn ease(motion: Motion, u: f32) f32 {
+ if (motion.instant or u >= 1) return 1;
+ if (u <= 0) return 0;
+ const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false };
+ return spring.value(@intFromFloat(u * std.time.ns_per_s));
+ }
+
+ /// The same move's speed at `u`, in lengths per unit of `u`.
+ pub fn speed(motion: Motion, u: f32) f32 {
+ const h: f32 = 1.0 / 256.0;
+ return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h));
+ }
};
/// A value that follows its target on a damped spring, in closed form
@@ -684,8 +753,8 @@ pub const Spring = struct {
return;
}
const state = spring.at(now_ns);
- // From rest, a wind-up: set off the other way, just a little.
- const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0;
+ // From rest, a launch, or a wind-up: set off the other way, a little.
+ const kick: f32 = if (spring.settled) (motion.launch - 2 * motion.anticipate) * (target - state.value) * motion.omega else 0;
spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false };
}
@@ -712,7 +781,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted"
try std.testing.expect(v >= last and v <= 1);
last = v;
}
- try std.testing.expect(t >= 150 * ms and t <= 260 * ms);
+ try std.testing.expect(t >= 80 * ms and t <= 200 * ms);
try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms));
// Back the other way partway up: it carries on up for a moment, then
// turns, never jumping.
@@ -723,7 +792,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted"
const after = spring.at(60 * ms);
try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6);
try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4);
- try std.testing.expect(spring.value(64 * ms) > before.value);
+ try std.testing.expect(spring.value(60 * ms + 500_000) > before.value);
}
test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" {
@@ -732,6 +801,7 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play
spring.retarget(1, 0, Motion.of(.off));
try std.testing.expect(spring.settled);
try std.testing.expectEqual(@as(f32, 1), spring.value(0));
+ var settle: [5]u64 = @splat(0);
for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| {
spring = .{};
spring.retarget(1, 0, Motion.of(flavour));
@@ -744,17 +814,60 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play
peak = @max(peak, now.value);
low = @min(low, now.value);
// Continuous, a millisecond at a time: no jump anywhere.
- try std.testing.expect(@abs(now.value - last.value) < 0.05);
+ try std.testing.expect(@abs(now.value - last.value) < 0.07);
last = now;
}
- try std.testing.expect(t < 1200 * ms);
+ try std.testing.expect(t < 1600 * ms);
+ settle[@intFromEnum(flavour)] = t;
switch (flavour) {
.crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0),
- .bouncy => try std.testing.expect(peak > 1.05 and low >= 0),
- .playful => try std.testing.expect(peak > 1.05 and low < -0.01),
+ // Clearly past the mark, not a hint of it.
+ .bouncy => try std.testing.expect(peak > 1.25 and low >= 0),
+ .playful => try std.testing.expect(peak > 1.35 and low < -0.03),
.off => unreachable,
}
}
+ // Smooth takes twice crisp's time and more: a character, not a tuning.
+ // Yet as the default a pane's arrival (a slide's 12 frames, scaled) is
+ // inside §8.1's 220 ms, and a lift is still before 300 ms.
+ try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] > 2 * settle[@intFromEnum(Motion.Flavour.crisp)]);
+ try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] <= 300 * ms);
+ try std.testing.expect(@round(@as(f32, 12) * Motion.of(.smooth).span) * frame_ms <= 220);
+ // A fixed-length move lands exactly on its mark, whatever the flavour.
+ for (std.enums.values(Motion.Flavour)) |flavour| try std.testing.expectEqual(@as(f32, 1), Motion.of(flavour).ease(1));
+}
+
+test "a flavour's stretched pane lands exactly on its box, and a click finds the glyph drawn there" {
+ const from: Box = .{ .x = 0, .y = 2, .w = 30, .h = 10 };
+ const to: Box = .{ .x = 37.3, .y = 2, .w = 30, .h = 10 };
+ for (std.enums.values(Motion.Flavour)) |flavour| {
+ var track: Track = .{ .effect = .slide, .motion = @intFromEnum(flavour) + 1, .frame_count = 20, .from = from, .to = to };
+ track.frame = track.frames() - 1;
+ const landed = track.presented();
+ // Bit for bit: no stretch or float residue once it lands.
+ try std.testing.expectEqual(to, landed);
+ }
+ // Playful at its most stretched: the pane is drawn wider than its box,
+ // and the drawn place of a cell maps back to that cell (the pointer
+ // inverts the same box: target + (x - shown.x) / shown.w * target.w).
+ var track: Track = .{ .effect = .slide, .motion = @intFromEnum(Motion.Flavour.playful) + 1, .frame_count = 20, .from = from, .to = to };
+ var most: u16 = 0;
+ var widest: f32 = 0;
+ while (track.frame < track.frames()) : (track.frame += 1) {
+ if (track.presented().w > widest) {
+ widest = track.presented().w;
+ most = track.frame;
+ }
+ }
+ track.frame = most;
+ const shown = track.presented();
+ try std.testing.expect(shown.w > to.w * 1.05);
+ for (0..30) |k| {
+ const cell = to.x + @as(f32, @floatFromInt(k)) + 0.5;
+ const drawn = shown.x + (cell - to.x) / to.w * shown.w;
+ const back = to.x + (drawn - shown.x) / shown.w * to.w;
+ try std.testing.expectApproxEqAbs(cell, back, 1e-3);
+ }
}
/// A displayed value that fades from one target to the next over