//! Animation curves and the panel transition records every shell reads: //! easing presets, transition kinds and their tracks, the fractional boxes //! they move through, the character effects' per-cell sources, and the //! generic displayed-value Transition the chrome colours use. const std = @import("std"); pub const ascii_max_movement_frames: u16 = 12; pub const Easing = enum(u8) { linear, smooth, /// Quintic ease-in-out. It creeps at both ends and crosses the middle of /// the distance fast, inside the same frame count a linear walk would use. smoother, in_cubic, out_cubic, out_back, }; pub const Transition = enum(u8) { // Numeric values are shared with the GUI shader ABI. off = 0, slide = 1, zoom = 2, dissolve = 3, ascii = 4, vertical = 5, edges = 6, fall = 7, wave = 8, curtain = 9, scramble = 10, typewriter = 11, pub fn easing(effect: Transition) Easing { return switch (effect) { .off, .dissolve, .wave => .smooth, .slide, .vertical, .edges => .out_cubic, .zoom => .out_back, // Character walks and per-cell locks read best with a slow start, // a fast middle, and a slow settle over their fixed frame count. .ascii, .fall, .scramble => .smoother, // A sweep and a typewriter are constant-rate by definition: easing // their head would make the pass visibly hesitate mid-pane. .curtain, .typewriter => .linear, }; } pub fn frames(effect: Transition) u16 { return switch (effect) { .off => 0, .slide => 12, .zoom => 14, .dissolve => 10, .ascii => ascii_max_movement_frames + 1, .vertical => 12, .edges, .curtain, .scramble => 12, // Travelling motion needs a couple more samples than a lock or a // rigid slide before it stops reading as a jump. .fall, .wave, .typewriter => 14, }; } pub fn composedByCore(effect: Transition) bool { return switch (effect) { .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, .off, .slide, .zoom, .dissolve, .vertical => false, }; } pub fn needsPreviousGrid(effect: Transition) bool { return effect == .dissolve or effect == .vertical or effect.composedByCore(); } pub fn lifecycleOnly(effect: Transition) bool { return effect == .vertical; } }; pub const Phase = enum(u8) { opening = 0, moving = 1, /// Presentation-only content whose pane lifetime has already ended. /// It is never a valid input target. closing = 2, }; pub const Box = extern struct { x: f32 = 0, y: f32 = 0, w: f32 = 0, h: f32 = 0, pub fn eql(a: Box, b: Box) bool { return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; } // A cell belongs to a fractional box when its center lies inside. pub fn contains(box: Box, col: u16, row: u16) bool { const x: f32 = @floatFromInt(col); const y: f32 = @floatFromInt(row); return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; } }; // Drawing and hit testing share this moving/opening/closing order. pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { var len: usize = 0; for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { const track = maybe orelse continue; if (!track.active() or track.phase != phase) continue; if (len == out.len) return len; out[len] = track; len += 1; }; for (closing) |track| { if (!track.active()) continue; if (len == out.len) return len; out[len] = track; len += 1; } return len; } /// One POD record is enough for every backend. `from` and `to` are logical /// cell boxes; frontends convert them to pixels only at their render edge. pub const Track = extern struct { serial: u32 = 0, pane: u8 = 0, phase: Phase = .moving, effect: Transition = .off, /// 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 = .{}, to: Box = .{}, /// The move's speed as it starts, in its lengths per unit of progress: /// 0 sets off as the flavour does; a move retargeted mid-way carries /// the old one's speed on (Presentation.sync), so it never stops dead. launch: f32 = 0, pub fn active(track: Track) bool { return track.effect != .off and track.frame < track.frames(); } pub fn frames(track: Track) u16 { return if (track.frame_count != 0) track.frame_count else track.effect.frames(); } pub fn amount(track: Track) f32 { // Opening rises quickly and settles; closing reverses that motion and // 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.easeFrom(track.progress(), track.launch); 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); } /// How fast its presented box moves now, in cells per frame of its /// own (x, y, w, h); zero when it follows no flavour or has landed. pub fn velocity(track: Track) Box { const motion = track.flavour() orelse return .{}; const u = track.progress(); if (u >= 1 or track.frames() <= 1) return .{}; const rate = motion.speedFrom(u, track.launch) / @as(f32, @floatFromInt(track.frames() - 1)); return .{ .x = (track.to.x - track.from.x) * rate, .y = (track.to.y - track.from.y) * rate, .w = (track.to.w - track.from.w) * rate, .h = (track.to.h - track.from.h) * rate }; } 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 { // 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.speedFrom(track.progress(), track.launch) / 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 { return switch (track.effect) { .slide, .zoom, .vertical => track.presented(), .off, .dissolve => track.to, // Every character effect stays inside the pane's final rectangle. .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, }; } pub fn contentBox(track: Track) Box { return if (track.phase == .closing) track.from else track.to; } }; pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { return switch (effect) { .slide => blk: { var from = target; const middle = target.x + target.w * 0.5; from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) -target.w else @floatFromInt(screen_width); break :blk from; }, .zoom => .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, .w = 0, .h = 0, }, .vertical => blk: { var from = target; from.y += target.h; break :blk from; }, .off, .dissolve => target, // Character effects own the glyphs inside a fixed rectangle, so their // panel opens at exactly its final geometry. .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, }; } pub fn closingBox(effect: Transition, source: Box) Box { return switch (effect) { .vertical => blk: { var to = source; to.y += source.h; break :blk to; }, else => source, }; } pub fn sample(easing: Easing, raw: f32) f32 { const t = std.math.clamp(raw, 0.0, 1.0); return switch (easing) { .linear => t, .smooth => t * t * (3.0 - 2.0 * t), .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), .in_cubic => t * t * t, .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), // Robert Penner's ease-out-back polynomial. It intentionally travels // a little past one before settling exactly on the endpoint. .out_back => blk: { const c1: f32 = 1.70158; const c3 = c1 + 1.0; const u = t - 1.0; break :blk 1.0 + c3 * u * u * u + c1 * u * u; }, }; } pub fn progress(effect: Transition, frame: u16) f32 { return progressEased(effect.easing(), effect.frames(), frame); } fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { if (frames <= 1 or frame >= frames - 1) return 1.0; return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); } pub fn lerpBox(from: Box, to: Box, t: f32) Box { const u = @max(0.0, t); return .{ .x = from.x + (to.x - from.x) * u, .y = from.y + (to.y - from.y) * u, .w = @max(0.0, from.w + (to.w - from.w) * u), .h = @max(0.0, from.h + (to.h - from.h) * u), }; } /// Stable cell noise shared by the TTY reveal and shader ports. Integer-only /// hashing means resizing or repainting a frame does not make cells flicker. pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); x ^= x >> 16; x *%= 0x7feb_352d; x ^= x >> 15; x *%= 0x846c_a68b; x ^= x >> 16; return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; } /// Whether a changed dissolve cell has crossed from the frozen old grid to /// the new one. Exact endpoints are part of the presentation contract. pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { const t = std.math.clamp(raw_progress, 0.0, 1.0); if (t <= 0) return false; if (t >= 1) return true; return cellNoise(serial, col, row) < t; } pub const CellArea = struct { x0: u16 = 0, y0: u16 = 0, cols: u16 = 1, rows: u16 = 1, pub fn of(box: Box) CellArea { return .{ .x0 = floorCell(box.x), .y0 = floorCell(box.y), .cols = ceilCell(box.w), .rows = ceilCell(box.h), }; } }; fn floorCell(value: f32) u16 { return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); } fn ceilCell(value: f32) u16 { return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); } pub const CharSource = union(enum) { /// Nothing has arrived here yet: keep the frozen old cell. old, at: Offset, /// Paint this printable byte in the destination cell's own style, whatever /// that cell holds — a caret marching over empty space is still a caret. byte: u8, churn: u8, pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; pub const settled: CharSource = .{ .at = .{} }; }; pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { const t = track.amount(); if (t >= 1.0) return .settled; const w: f32 = @floatFromInt(area.cols); const h: f32 = @floatFromInt(area.rows); const c: f32 = @floatFromInt(col); const r: f32 = @floatFromInt(row); const remaining = 1.0 - t; return switch (track.effect) { .edges => blk: { const travel = cellsOf(remaining * (w + 1.0)); break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; }, // Columns rain down, each with its own stable head start, so the pane // fills from the top and the last glyphs land at the bottom. .fall => blk: { const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); if (local <= 0.0) break :blk .old; break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; }, // A vertical ripple travels left to right and its amplitude decays, so // the pane settles out of a wave instead of a fade. .wave => .{ .at = .{ .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), } }, // A curtain of glyphs marches in from the right, column by column, left // to right; each column still has a short slide of its own. .curtain => blk: { const lead = t * (w + 1.0) - c; if (lead <= 0.0) break :blk .old; break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; }, // Every cell churns through printable ASCII and locks onto its final // glyph at its own stable threshold: the pane resolves out of noise. .scramble => blk: { if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; const churn = cellNoise( track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), col, row, ); break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; }, // Reading-order reveal with a caret sitting on the write head. .typewriter => blk: { const head = t * w * h; const index = r * w + c; if (index + 1.0 <= head) break :blk .settled; if (index <= head) break :blk .{ .byte = '_' }; break :blk .old; }, // PanelAscii walks its own byte distance per cell, and the geometry // effects never reach this path at all. .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, }; } fn cellsOf(distance: f32) i32 { return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); } /// Remap track progress into one cell's own window. A stagger delays a glyph /// without making the effect as a whole end after its last frame. fn staggered(t: f32, delay: f32) f32 { if (delay >= 1.0) return t; return (t - delay) / (1.0 - delay); } test "easing presets have exact endpoints and intended shapes" { inline for (std.enums.values(Easing)) |easing| { try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); } try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); try std.testing.expect(sample(.out_back, 0.8) > 1.0); // Slow at both ends, fast through the middle, and symmetric about the // halfway point: the same curve the integer byte walk reproduces. try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > sample(.linear, 0.6) - sample(.linear, 0.4)); } test "transition progress completes exactly" { inline for (std.enums.values(Transition)) |effect| { try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); if (effect.frames() > 0) try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); } try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); try std.testing.expectEqual(@as(f32, 0), shrinking.w); try std.testing.expectEqual(@as(f32, 0), shrinking.h); const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); try std.testing.expect(opening.w > 100); try std.testing.expect(opening.h > 40); } test "character effects are core-composed and settle on the canonical glyph" { const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; const area: CellArea = .of(box); try std.testing.expectEqual(@as(u16, 4), area.x0); try std.testing.expectEqual(@as(u16, 2), area.y0); try std.testing.expectEqual(@as(u16, 20), area.cols); try std.testing.expectEqual(@as(u16, 6), area.rows); inline for (std.enums.values(Transition)) |effect| { if (comptime !effect.composedByCore()) continue; // Core composition needs the frozen old grid for every glyph which has // not arrived, so no character effect may animate without it. try std.testing.expect(effect.needsPreviousGrid()); if (comptime effect == .ascii) continue; // owns its own per-cell byte walk const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; const first: Track = .{ .effect = effect, .frame = 0, .to = box }; var moving = false; var row: u16 = 0; while (row < area.rows) : (row += 1) { var col: u16 = 0; while (col < area.cols) : (col += 1) { // The last active sample is the exact canonical grid: no cell // is displaced, churning, or still frozen. try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) moving = true; } } try std.testing.expect(moving); } } test "each character effect moves glyphs along its own axis" { const box: Box = .{ .w = 30, .h = 8 }; const area: CellArea = .of(box); // Rows alternate which screen edge they come from, and every glyph in a row // travels as one rigid slide: one offset, no vertical component. var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; const even = charSource(edges, 5, 0, area).at; const odd = charSource(edges, 5, 1, area).at; try std.testing.expect(even.cols > 0); try std.testing.expectEqual(-even.cols, odd.cols); try std.testing.expectEqual(@as(i32, 0), even.rows); try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); edges.frame = 5; try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); // Falling columns are vertical only, staggered, and sample from below the // destination because the new text is still above the pane. const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; var falling = false; var col: u16 = 0; while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { .old => {}, .byte, .churn => return error.FallShouldNotChurn, .at => |offset| { try std.testing.expectEqual(@as(i32, 0), offset.cols); try std.testing.expect(offset.rows >= 0); if (offset.rows > 0) falling = true; }, }; try std.testing.expect(falling); // The wave displaces rows both ways as it travels, and only rows. const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; var above = false; var below = false; col = 0; while (col < area.cols) : (col += 1) { const offset = charSource(wave, col, 3, area).at; try std.testing.expectEqual(@as(i32, 0), offset.cols); if (offset.rows < 0) above = true; if (offset.rows > 0) below = true; } try std.testing.expect(above and below); // The curtain has a head: columns behind it hold the old grid, columns the // head has passed are settled, and the head itself is still sliding. const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); var sliding = false; col = 0; while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { .at => |offset| if (offset.cols < 0) { sliding = true; }, .old, .byte, .churn => {}, }; try std.testing.expect(sliding); var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; var churning: usize = 0; var locked: usize = 0; var changed = false; col = 0; while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { .churn => |byte| { try std.testing.expect(byte >= ' ' and byte <= '~'); churning += 1; scramble.frame = 4; switch (charSource(scramble, col, 0, area)) { .churn => |next| changed = changed or next != byte, .old, .at, .byte => {}, } scramble.frame = 3; }, .at => locked += 1, .old, .byte => return error.ScrambleShouldNotFreeze, }; try std.testing.expect(churning > 0 and locked > 0 and changed); // The typewriter writes in reading order with a caret on its head. const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); try std.testing.expectEqual( CharSource{ .old = {} }, charSource(typewriter, area.cols - 1, area.rows - 1, area), ); var carets: usize = 0; var row: u16 = 0; while (row < area.rows) : (row += 1) { col = 0; while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { .byte => |byte| { try std.testing.expectEqual(@as(u8, '_'), byte); carets += 1; }, .old, .at, .churn => {}, }; } try std.testing.expectEqual(@as(usize, 1), carets); } test "opening presets separate geometry and content transitions" { const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); var track: Track = .{ .effect = .slide, .from = target, .to = target }; try std.testing.expect(track.active()); track.frame = track.effect.frames(); try std.testing.expect(!track.active()); track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; try std.testing.expectEqual(target, track.visualBox()); var closing: Track = .{ .phase = .closing, .effect = .vertical, .from = target, .to = closingBox(.vertical, target), }; try std.testing.expectEqual(target, closing.contentBox()); closing.frame = 2; try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); } test "dissolve has exact stable endpoints" { for (0..64) |col| { const x: u16 = @intCast(col); try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); try std.testing.expect(dissolveRevealed(42, x, 7, 1)); if (dissolveRevealed(42, x, 7, 0.25)) try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); } } pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; /// How the fx track moves (`Motion `, 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, /// 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 = 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 { return motion.easeFrom(u, 0); } /// `ease` for a move that starts already moving at `launch` lengths /// per unit of `u` (a retarget): no wind-up then, the speed carried on. pub fn easeFrom(motion: Motion, u: f32, launch: f32) f32 { if (motion.instant or u >= 1) return 1; if (u <= 0) return 0; const kick = if (launch != 0) launch else (motion.launch - 2 * motion.anticipate) * motion.norm; const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = kick, .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 { return motion.speedFrom(u, 0); } pub fn speedFrom(motion: Motion, u: f32, launch: f32) f32 { const h: f32 = 1.0 / 256.0; return (motion.easeFrom(@min(1, u + h), launch) - motion.easeFrom(@max(0, u - h), launch)) / (@min(1, u + h) - @max(0, u - h)); } }; /// 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, velocity: f32 = 0, from_ns: u64 = 0, settled: bool = true, const State = struct { value: f32, velocity: f32 }; fn at(spring: *const Spring, now_ns: u64) State { if (spring.settled) return .{ .value = spring.target, .velocity = 0 }; 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 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; } /// Moves what it shows by `by` at `now_ns`, its speed kept, toward the /// same target (a scroll's picture falling behind the text it follows), /// at `motion`'s pace, never past its mark. pub fn carry(spring: *Spring, by: f32, now_ns: u64, motion: Motion) void { const state = spring.at(now_ns); spring.* = .{ .omega = motion.omega, .zeta = 1, .target = spring.target, .from = state.value + by, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; } /// 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); // 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 }; } /// Settles once it is within half a percent of its target and barely /// moving (no pixel of a shadow tells the rest): it is its target from /// then on. True while it still moves. pub fn step(spring: *Spring, now_ns: u64) bool { if (spring.settled) return false; const state = spring.at(now_ns); if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true; return !spring.settled; } }; test "a carried spring keeps its speed, heads back to its mark and never passes it" { const ms = std.time.ns_per_ms; var spring: Spring = .{}; const smooth = Motion.of(.smooth); spring.carry(-3, 0, smooth); try std.testing.expectEqual(@as(f32, -3), spring.value(0)); const mid = spring.at(40 * ms); try std.testing.expect(mid.value > -3 and mid.value < 0 and mid.velocity > 0); // Another notch mid-way: one more row behind, still moving back. spring.carry(-1, 40 * ms, smooth); const after = spring.at(40 * ms); try std.testing.expectApproxEqAbs(mid.value - 1, after.value, 1e-5); try std.testing.expectApproxEqAbs(mid.velocity, after.velocity, 1e-4); var t: u64 = 40 * ms; while (spring.step(t)) : (t += frame_ns) try std.testing.expect(spring.value(t) <= 1e-4); try std.testing.expect(t < 400 * ms); } test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; 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) { const v = spring.value(t); try std.testing.expect(v >= last and v <= 1); last = v; } 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. spring = .{}; spring.retarget(1, 0, crisp); const before = spring.at(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(60 * ms + 500_000) > 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)); var settle: [5]u64 = @splat(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.07); last = now; } 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), // 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); } } /// The focused cursor's quad (docs/render-pipeline.md §5.4), in grid cells: /// four corners, each on its own springs, so a jump smears along its path /// (the leading corners stiffer than the trailing ones, Motion.follow) and /// collapses at rest; a long jump bows (Motion.arc). A move of a cell or /// less, or any the caller calls a snap, lands at once. pub const CursorGlide = struct { /// Top left, top right, bottom right, bottom left; x then y. corners: [4][2]Spring = @splat(@splat(.{})), target: Box = .{}, from_centre: [2]f32 = .{ 0, 0 }, placed: bool = false, /// How much faster than the flavour's own pace a cursor moves: it /// follows input (§8.1: settles in 90-150 ms on the default). pub const pace: f32 = 2.4; /// A jump longer than this, in cells, bows. pub const arc_from: f32 = 8; fn cornersOf(box: Box) [4][2]f32 { return .{ .{ box.x, box.y }, .{ box.x + box.w, box.y }, .{ box.x + box.w, box.y + box.h }, .{ box.x, box.y + box.h } }; } fn centre(box: Box) [2]f32 { return .{ box.x + box.w / 2, box.y + box.h / 2 }; } /// Heads for `box` at `now_ns`. pub fn aim(glide: *CursorGlide, box: Box, now_ns: u64, motion: Motion, snap: bool) void { // The same mark: nothing to do, unless a snap lands a glide under way. if (glide.placed and std.meta.eql(box, glide.target) and !(snap and glide.moving())) return; const to = cornersOf(box); const from_c = if (glide.placed) glide.centreAt(now_ns) else centre(box); const to_c = centre(box); const dx = to_c[0] - from_c[0]; const dy = to_c[1] - from_c[1]; const jump = @abs(dx) > 1.01 or @abs(dy) > 1.01; const land = !glide.placed or snap or motion.instant or !jump; glide.from_centre = from_c; glide.target = box; glide.placed = true; for (&glide.corners, to) |*corner, point| { // Leading: the corner ahead of the move, on the side it goes. const ahead = (point[0] - to_c[0]) * dx + (point[1] - to_c[1]) * dy; var own = motion; own.omega *= pace * (if (ahead > 0) 1 else motion.follow); if (land) { for (corner, point) |*axis, value| axis.* = .{ .omega = own.omega, .zeta = own.zeta, .target = value, .from = value }; continue; } for (corner, point) |*axis, value| axis.retarget(value, now_ns, own); } } fn centreAt(glide: *const CursorGlide, now_ns: u64) [2]f32 { var c: [2]f32 = .{ 0, 0 }; for (glide.corners) |corner| for (&c, corner) |*sum, axis| { sum.* += axis.value(now_ns) / 4; }; return c; } /// Text under the cursor moved by (dx, dy) cells (a scroll): the quad /// moves with it, rather than gliding against text that jumped. pub fn shift(glide: *CursorGlide, dx: f32, dy: f32) void { for (&glide.corners) |*corner| for (corner, [2]f32{ dx, dy }) |*axis, d| { axis.from += d; axis.target += d; }; glide.from_centre = .{ glide.from_centre[0] + dx, glide.from_centre[1] + dy }; glide.target.x += dx; glide.target.y += dy; } /// The quad at `now_ns`, bowed on a long jump by the flavour's arc. pub fn sample(glide: *const CursorGlide, now_ns: u64, motion: Motion) [4][2]f32 { var out: [4][2]f32 = undefined; for (&out, glide.corners) |*point, corner| for (point, corner) |*value, axis| { value.* = axis.value(now_ns); }; const to_c = centre(glide.target); const dx = to_c[0] - glide.from_centre[0]; const dy = to_c[1] - glide.from_centre[1]; const length = @sqrt(dx * dx + dy * dy); if (motion.arc == 0 or length < arc_from) return out; const now_c = glide.centreAt(now_ns); const left = @sqrt((to_c[0] - now_c[0]) * (to_c[0] - now_c[0]) + (to_c[1] - now_c[1]) * (to_c[1] - now_c[1])); const travelled = std.math.clamp(1 - left / length, 0, 1); const bow = motion.arc * length * 4 * travelled * (1 - travelled); // Perpendicular to the path, to its left. for (&out) |*point| { point[0] += -dy / length * bow; point[1] += dx / length * bow; } return out; } /// Steps the springs to `now_ns`; true while the quad still moves. pub fn step(glide: *CursorGlide, now_ns: u64) bool { var any = false; for (&glide.corners) |*corner| for (corner) |*axis| { if (axis.step(now_ns)) any = true; }; return any; } /// How far, in cells, the furthest corner still is from its mark. pub fn remaining(glide: *const CursorGlide, now_ns: u64) f32 { var most: f32 = 0; for (glide.corners) |corner| for (corner) |axis| { most = @max(most, @abs(axis.value(now_ns) - axis.target)); }; return most; } pub fn moving(glide: *const CursorGlide) bool { for (glide.corners) |corner| for (corner) |axis| { if (!axis.settled) return true; }; return false; } }; /// The cursor's opacity under blink (§5.4): solid while typing and for /// `hold_ns` after, then on and off with an eased edge, solid again after /// `idle_ns` of no input. `edge` says whether it is mid-edge (a frame is /// owed), `next_ns` when the next edge starts (a wake). pub const Blink = struct { pub const hold_ns: u64 = 500 * std.time.ns_per_ms; pub const idle_ns: u64 = 10 * std.time.ns_per_s; pub const half_ns: u64 = 530 * std.time.ns_per_ms; pub const edge_ns: u64 = 80 * std.time.ns_per_ms; alpha: f32, edge: bool, next_ns: ?u64, pub fn at(now_ns: u64, input_ns: u64) Blink { const since = now_ns -| input_ns; if (since < hold_ns) return .{ .alpha = 1, .edge = false, .next_ns = input_ns + hold_ns + half_ns - edge_ns }; if (since >= hold_ns + idle_ns) return .{ .alpha = 1, .edge = false, .next_ns = null }; const t = (since - hold_ns) % (2 * half_ns); const cycle_start = now_ns - t; // On for a half, easing out over its last `edge_ns`; off for a half, // easing back in over its last. if (t < half_ns - edge_ns) return .{ .alpha = 1, .edge = false, .next_ns = cycle_start + half_ns - edge_ns }; if (t < half_ns) return .{ .alpha = 1 - smooth(@as(f32, @floatFromInt(t - (half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null }; if (t < 2 * half_ns - edge_ns) return .{ .alpha = 0, .edge = false, .next_ns = cycle_start + 2 * half_ns - edge_ns }; return .{ .alpha = smooth(@as(f32, @floatFromInt(t - (2 * half_ns - edge_ns))) / @as(f32, @floatFromInt(edge_ns))), .edge = true, .next_ns = null }; } fn smooth(x: f32) f32 { const u = std.math.clamp(x, 0, 1); return u * u * (3 - 2 * u); } }; test "a cursor snaps a cell at a time and glides a jump, smearing, bowing and landing exactly" { const ms = std.time.ns_per_ms; const smooth_motion = Motion.of(.smooth); var glide: CursorGlide = .{}; const cell: Box = .{ .x = 3, .y = 4, .w = 1, .h = 1 }; glide.aim(cell, 0, smooth_motion, false); try std.testing.expect(!glide.moving()); // One cell over: lands at once. glide.aim(.{ .x = 4, .y = 4, .w = 1, .h = 1 }, ms, smooth_motion, false); try std.testing.expect(!glide.moving()); // A jump of 30 cells: glides, the leading edge ahead of the trailing one. const far: Box = .{ .x = 34, .y = 4, .w = 1, .h = 1 }; glide.aim(far, 2 * ms, smooth_motion, false); try std.testing.expect(glide.moving()); const mid = glide.sample(40 * ms, smooth_motion); const width = mid[1][0] - mid[0][0]; try std.testing.expect(width > 1.5); // Bowed off the straight line on a long jump. try std.testing.expect(@abs(mid[0][1] - 4) > 0.05); var t: u64 = 2 * ms; while (glide.step(t)) t += ms; // Arrives within §8.1's 90-150 ms on the default, within a twentieth of // a cell (under a pixel); settles to rest just after, landing exactly. const arrived = glide.sample(152 * ms, smooth_motion); try std.testing.expect(@abs(arrived[3][0] - 34) < 0.05 and @abs(arrived[1][0] - 35) < 0.05); try std.testing.expect(t <= 220 * ms); const rest = glide.sample(t, smooth_motion); try std.testing.expectEqual([2]f32{ 34, 4 }, rest[0]); try std.testing.expectEqual([2]f32{ 35, 5 }, rest[2]); // A scroll carries it with the text. glide.shift(0, -3); try std.testing.expectEqual([2]f32{ 34, 1 }, glide.sample(t, smooth_motion)[0]); } test "a blink holds while typing, eases at its edges and stops when idle" { const ms = std.time.ns_per_ms; try std.testing.expectEqual(@as(f32, 1), Blink.at(100 * ms, 0).alpha); const out = Blink.at(Blink.hold_ns + Blink.half_ns + 100 * ms, 0); try std.testing.expectEqual(@as(f32, 0), out.alpha); const edge = Blink.at(Blink.hold_ns + Blink.half_ns - Blink.edge_ns / 2, 0); try std.testing.expect(edge.edge and edge.alpha > 0.2 and edge.alpha < 0.8); try std.testing.expectEqual(@as(?u64, null), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).next_ns); try std.testing.expectEqual(@as(f32, 1), Blink.at(Blink.hold_ns + Blink.idle_ns + ms, 0).alpha); } /// 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 { return struct { const Self = @This(); from: Value, to: Value, displayed: Value, step: u16 = transition_steps, /// Set off from partway through another fade: it eases out (moving /// at once) instead of in and out, so it never stalls (§7.2). retargeted: bool = false, pub fn init(value: Value) Self { return .{ .from = value, .to = value, .displayed = value }; } pub fn isActive(a: *const Self) bool { return a.step < transition_steps; } pub fn retarget(a: *Self, target: Value) void { a.retargeted = a.isActive(); a.from = a.displayed; a.to = target; a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; if (a.step == transition_steps) a.displayed = target; } /// A state change eases in and out (§8.1); a retargeted one out. const resolution: u16 = 1000; pub fn advance(a: *Self) void { if (!a.isActive()) return; a.step += 1; const t = @as(f32, @floatFromInt(a.step)) / @as(f32, @floatFromInt(transition_steps)); const eased = sample(if (a.retargeted) .out_cubic else .smooth, t); a.displayed = if (a.step == transition_steps) a.to else Value.interpolate(a.from, a.to, @intFromFloat(@round(eased * resolution)), resolution); } pub fn snap(a: *Self, value: Value) void { a.* = init(value); } }; } pub fn Immediate(comptime Value: type) type { return struct { const Self = @This(); displayed: Value, pub fn init(value: Value) Self { return .{ .displayed = value }; } pub fn isActive(_: *const Self) bool { return false; } pub fn retarget(a: *Self, target: Value) void { a.displayed = target; } pub fn advance(_: *Self) void {} pub fn snap(a: *Self, value: Value) void { a.displayed = value; } }; } pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { if (step == 0) return from; if (step >= steps) return to; var out: [3]u8 = undefined; for (&out, from, to) |*dst, a, b| { const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; dst.* = @intCast((numerator + steps / 2) / steps); } return out; } const TestColor = struct { rgb: [3]u8, pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; } }; test "Immediate lands where a completed Transition lands" { const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; var faded = Fade(TestColor).init(from); faded.retarget(to); for (0..transition_steps) |_| faded.advance(); var instant = Immediate(TestColor).init(from); try std.testing.expect(!instant.isActive()); instant.retarget(to); try std.testing.expectEqual(faded.displayed, instant.displayed); // Never active, so a frontend that renders only while something is animating stops immediately // rather than spending ten frames discovering there is nothing to draw. try std.testing.expect(!instant.isActive()); instant.advance(); try std.testing.expectEqual(to, instant.displayed); instant.snap(from); try std.testing.expectEqual(from, instant.displayed); } test "fixed-step interpolation has exact monotonic endpoints" { const Tween = Fade(TestColor); const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; var tween = Tween.init(from); tween.retarget(to); try std.testing.expectEqual(from, tween.displayed); var previous = tween.displayed; for (0..transition_steps) |_| { tween.advance(); try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); previous = tween.displayed; } try std.testing.expect(!tween.isActive()); try std.testing.expectEqual(to, tween.displayed); tween.advance(); try std.testing.expectEqual(to, tween.displayed); } test "retarget starts at the currently displayed value" { const Tween = Fade(TestColor); const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; var tween = Tween.init(first); tween.retarget(second); tween.advance(); tween.advance(); tween.advance(); const on_screen = tween.displayed; tween.retarget(third); try std.testing.expectEqual(on_screen, tween.from); try std.testing.expectEqual(on_screen, tween.displayed); try std.testing.expect(tween.isActive()); for (0..transition_steps) |_| tween.advance(); try std.testing.expectEqual(third, tween.displayed); }