summaryrefslogtreecommitdiff
path: root/src/animation.zig
diff options
context:
space:
mode:
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