diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 18:24:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 5dbd21795f6c68bd3c10d409135970e3033364f3 (patch) | |
| tree | 7dcea4712daa0dcd46e8b8438ca00b2001a4aed4 /src/animation.zig | |
| parent | f41d0209f65237568c9f7d527d5989ac8921b74c (diff) | |
| download | pardes-5dbd21795f6c68bd3c10d409135970e3033364f3.tar.gz pardes-5dbd21795f6c68bd3c10d409135970e3033364f3.zip | |
Motion flavours, and Lift is shadow, rim or auto: auto on a dark page recedes the other panes (InactiveDim)
Motion off|crisp|smooth|bouncy|playful is one parameter set (animation.Motion)
every fx animation reads. One focus spring per pane drives both the lift and
the dim, at the flavour's pace; a grid snaps.
Lift drops glow and surface. A shadow falls on pane bodies and rails only,
capped so text and the selection keep min(contrast, 4.5); rim is a hairline
along the focused tag's top. auto is a shadow on a light page; on a dark page
the core recedes the unfocused panes' text (InactiveDim, ported from 2e4d97f,
default 30 under auto) with the same floor. Tests: focused text contrast is
never below unfocused in any style, dim or theme; the focused pane's cells and
tag are untouched by a dim.
Shared files: src/config.zig, src/builtins.zig, src/ninep/ctl.zig,
src/surface.zig, src/detached/wire.zig, docs/config.md,
test/snapshots/builtins.snap (one more wheel for the longer help).
Diffstat (limited to 'src/animation.zig')
| -rw-r--r-- | src/animation.zig | 139 |
1 files changed, 115 insertions, 24 deletions
diff --git a/src/animation.zig b/src/animation.zig index ca933b82..b02a242b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,19 +586,58 @@ pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; -/// A value that follows its target on a critically damped spring, in closed -/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken -/// partway keeps the value AND its velocity, so a quick run of changes never -/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump -/// after about 6.6 / omega seconds, and settles (half a percent, nearly -/// still) at about 8.6 / omega. Settled, it is exactly its target and asks -/// for no frames. -pub const Spring = struct { - /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a - /// state change (§8.1). - pub const state_change: f32 = 36; +/// How the fx track moves (`Motion <flavour>`, docs/effects.md): one set of +/// parameters a flavour picks, read by every animation that follows the +/// classic principles, so a flavour is data and no effect branches on it. +pub const Motion = struct { + pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful }; + + /// Nothing moves: every change lands at once (reduced motion). + instant: bool = false, + /// Timing: the spring's natural frequency, rad/s; slow in / slow out + /// come from the spring itself. + omega: f32 = 36, + /// Follow-through: 1 is critically damped (no overshoot); below 1 it + /// settles past its target and back. + zeta: f32 = 1, + /// Anticipation: a move from rest first winds up the other way, by + /// this fraction of the distance. + anticipate: f32 = 0, + /// Squash and stretch: how much a moving thing lengthens along its + /// velocity and squashes as it lands (G3's cursor, G7's panes). + stretch: f32 = 0, + /// Secondary action: how far behind a follower trails its leader (the + /// shadow behind a pane, a notice's text behind its chip), as the + /// follower's frequency over the leader's; 1 is in step. + follow: f32 = 1, + /// Arcs: how far a two-dimensional path bows, as a fraction of its + /// length (the cursor's glide). + arc: f32 = 0, - omega: f32 = state_change, + 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 }, + }; + } +}; + +/// A value that follows its target on a damped spring, in closed form +/// (docs/render-pipeline.md §7.2), critically damped or under: a new target +/// taken partway keeps the value AND its velocity, so a quick run of changes +/// never snaps or starts over. A move from rest can wind up first +/// (Motion.anticipate). Settled, it is exactly its target and asks for no +/// frames. +pub const Spring = struct { + omega: f32 = 36, + zeta: f32 = 1, target: f32 = 0, /// The value and velocity (per second) at `from_ns`. from: f32 = 0, @@ -613,20 +652,41 @@ pub const Spring = struct { const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s); const w = spring.omega; const x0 = spring.from - spring.target; - const c = spring.velocity + w * x0; - const decay = @exp(-w * t); - return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay }; + const v0 = spring.velocity; + if (spring.zeta >= 1) { + const c = v0 + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay }; + } + // Underdamped: it rings about its target as it decays. + const z = spring.zeta; + const wd = w * @sqrt(1 - z * z); + const b = (v0 + z * w * x0) / wd; + const decay = @exp(-z * w * t); + const cos = @cos(wd * t); + const sin = @sin(wd * t); + return .{ + .value = spring.target + decay * (x0 * cos + b * sin), + .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin), + }; } pub fn value(spring: *const Spring, now_ns: u64) f32 { return spring.at(now_ns).value; } - /// Heads for `target` from wherever it is at `now_ns`, moving as it was. - pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void { + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, + /// at the pace and damping `motion` gives: at once when it is `off`. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { if (target == spring.target) return; + if (motion.instant) { + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = target }; + return; + } const state = spring.at(now_ns); - spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + // 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; + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } /// Settles once it is within half a percent of its target and barely @@ -643,7 +703,8 @@ pub const Spring = struct { test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; - spring.retarget(1, 0); + const crisp = Motion.of(.crisp); + spring.retarget(1, 0, crisp); var last: f32 = 0; var t: u64 = 0; while (spring.step(t)) : (t += frame_ns) { @@ -651,21 +712,51 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" try std.testing.expect(v >= last and v <= 1); last = v; } - // Settled within a frame or two of 6.6 / omega. - try std.testing.expect(t >= 180 * ms and t <= 260 * ms); + try std.testing.expect(t >= 150 * ms and t <= 260 * ms); try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); - // Back the other way halfway up: it carries on up for a moment, then + // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. spring = .{}; - spring.retarget(1, 0); + spring.retarget(1, 0, crisp); const before = spring.at(60 * ms); - spring.retarget(0, 60 * ms); + spring.retarget(0, 60 * ms, crisp); 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); } +test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + spring.retarget(1, 0, Motion.of(.off)); + try std.testing.expect(spring.settled); + try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { + spring = .{}; + spring.retarget(1, 0, Motion.of(flavour)); + var peak: f32 = 0; + var low: f32 = 0; + var t: u64 = 0; + var last = spring.at(0); + while (spring.step(t)) : (t += ms) { + const now = spring.at(t); + 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); + last = now; + } + try std.testing.expect(t < 1200 * ms); + 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), + .off => unreachable, + } + } +} + /// A displayed value that fades from one target to the next over /// `transition_steps` frames; the chrome colours are one. pub fn Fade(comptime Value: type) type { |
