///|
#derive(Eq, Debug, ToJson)
pub(all) struct Frame {
time_ms : Double
delta_ms : Double
}
///|
pub extend Frame with Debug::{to_repr}
///|
pub extend Frame with Eq::{not_equal, equal}
///|
pub extend Frame with ToJson::{to_json}
///|
#derive(Eq, Debug, ToJson)
pub(all) enum AnimationStatus {
Idle
Running
Completed
}
///|
pub extend AnimationStatus with Debug::{to_repr}
///|
pub extend AnimationStatus with Eq::{not_equal, equal}
///|
pub extend AnimationStatus with ToJson::{to_json}
///|
#derive(Eq, Debug, ToJson)
pub(all) enum Easing {
Linear
EaseIn
EaseOut
EaseInOut
}
///|
pub extend Easing with Debug::{to_repr}
///|
pub extend Easing with Eq::{not_equal, equal}
///|
pub extend Easing with ToJson::{to_json}
///|
priv struct Tween {
from : Double
to : Double
easing : Easing
}
///|
#derive(Eq, Debug, ToJson)
pub struct TransitionStyle {
opacity : Double
offset : Point
scale : Point
foreground : Color?
}
///|
pub extend TransitionStyle with Debug::{to_repr}
///|
pub extend TransitionStyle with Eq::{not_equal, equal}
///|
pub extend TransitionStyle with ToJson::{to_json}
///|
#derive(Eq, Debug, ToJson)
pub struct TransitionSpec {
from : TransitionStyle
to : TransitionStyle
duration_ms : Double
easing : Easing
spring : SpringSpec?
}
///|
pub extend TransitionSpec with Debug::{to_repr}
///|
pub extend TransitionSpec with Eq::{not_equal, equal}
///|
pub extend TransitionSpec with ToJson::{to_json}
///|
#derive(Eq, Debug, ToJson)
pub struct TransitionSample {
progress : Double
style : TransitionStyle
present : Bool
}
///|
pub extend TransitionSample with Debug::{to_repr}
///|
pub extend TransitionSample with Eq::{not_equal, equal}
///|
pub extend TransitionSample with ToJson::{to_json}
///|
pub fn Frame::new(time_ms~ : Double, delta_ms~ : Double) -> Frame {
{ time_ms, delta_ms, }
}
///|
pub fn Easing::sample(self : Easing, progress : Double) -> Double {
let t = clamp_double(progress, 0.0, 1.0)
match self {
Linear => t
EaseIn => t * t
EaseOut => 1.0 - (1.0 - t) * (1.0 - t)
EaseInOut =>
if t < 0.5 {
2.0 * t * t
} else {
1.0 - (-2.0 * t + 2.0) * (-2.0 * t + 2.0) / 2.0
}
}
}
///|
fn Tween::new(from~ : Double, to~ : Double, easing~ : Easing = Linear) -> Tween {
{ from, to, easing, }
}
///|
fn Tween::value(self : Tween, progress : Double) -> Double {
self.from + (self.to - self.from) * self.easing.sample(progress)
}
///|
pub fn animated_value(
from~ : Double,
to~ : Double,
progress~ : Double,
easing~ : Easing = EaseInOut,
reduced_motion~ : Bool = false,
) -> Double {
Tween::new(from~, to~, easing~).value(
if reduced_motion {
1.0
} else {
progress
},
)
}
///|
pub fn animated_point(
from~ : Point,
to~ : Point,
progress~ : Double,
easing~ : Easing = EaseInOut,
reduced_motion~ : Bool = false,
) -> Point {
let sampled = easing.sample(if reduced_motion { 1.0 } else { progress })
Point::new(
x=from.x + (to.x - from.x) * sampled,
y=from.y + (to.y - from.y) * sampled,
)
}
///|
pub fn animated_color(
from~ : Color,
to~ : Color,
progress~ : Double,
easing~ : Easing = EaseInOut,
reduced_motion~ : Bool = false,
) -> Color {
let sampled = easing.sample(if reduced_motion { 1.0 } else { progress })
// Interpolate in premultiplied-alpha space. Straight RGBA lerp between a
// transparent color (e.g. `rgba(0,0,0,0)`) and an opaque light tint (e.g.
// `surface_variant`) walks the RGB toward the tint at half alpha, producing
// a mid-transition dark gray that flashes against the surface — "light →
// dark → light". Premultiplying keeps the visible hue at the destination
// throughout and only ramps coverage, so a transparent→opaque-tint fade
// stays the tint's color the whole way. When both endpoints are opaque this
// reduces to the same lerp as before, so opaque→opaque transitions are
// unchanged.
let from_pa = from.r * from.a
let from_pg = from.g * from.a
let from_pb = from.b * from.a
let to_pa = to.r * to.a
let to_pg = to.g * to.a
let to_pb = to.b * to.a
let pa = from_pa + (to_pa - from_pa) * sampled
let pg = from_pg + (to_pg - from_pg) * sampled
let pb = from_pb + (to_pb - from_pb) * sampled
let a = from.a + (to.a - from.a) * sampled
// Un-premultiply back to straight RGBA for the renderer. A fully transparent
// result has no defined hue; carry the destination RGB so a later increase
// in alpha doesn't resurrect a stray color.
if a <= 0.0 {
Color::rgba(r=to.r, g=to.g, b=to.b, a=0.0)
} else {
Color::rgba(r=pa / a, g=pg / a, b=pb / a, a~)
}
}
///|
pub fn TransitionStyle::identity() -> TransitionStyle {
{
opacity: 1.0,
offset: Point::new(x=0.0, y=0.0),
scale: Point::new(x=1.0, y=1.0),
foreground: None,
}
}
///|
pub fn TransitionStyle::new(
opacity~ : Double = 1.0,
offset~ : Point = Point::new(x=0.0, y=0.0),
scale~ : Point = Point::new(x=1.0, y=1.0),
foreground~ : Color? = None,
) -> TransitionStyle {
{ opacity, offset, scale, foreground, }
}
///|
pub fn TransitionStyle::with_opacity(
self : TransitionStyle,
opacity : Double,
) -> TransitionStyle {
{ ..self, opacity, }
}
///|
pub fn TransitionStyle::with_offset(
self : TransitionStyle,
dx~ : Double = self.offset.x,
dy~ : Double = self.offset.y,
) -> TransitionStyle {
{ ..self, offset: Point::new(x=dx, y=dy), }
}
///|
pub fn TransitionStyle::with_scale(
self : TransitionStyle,
sx~ : Double = self.scale.x,
sy~ : Double = self.scale.y,
) -> TransitionStyle {
{ ..self, scale: Point::new(x=sx, y=sy), }
}
///|
pub fn TransitionStyle::with_foreground(
self : TransitionStyle,
color : Color,
) -> TransitionStyle {
{ ..self, foreground: Some(color), }
}
///|
pub fn TransitionStyle::interpolate(
self : TransitionStyle,
to : TransitionStyle,
progress~ : Double,
easing~ : Easing = EaseInOut,
reduced_motion~ : Bool = false,
) -> TransitionStyle {
{
opacity: animated_value(
from=self.opacity,
to=to.opacity,
progress~,
easing~,
reduced_motion~,
),
offset: animated_point(
from=self.offset,
to=to.offset,
progress~,
easing~,
reduced_motion~,
),
scale: animated_point(
from=self.scale,
to=to.scale,
progress~,
easing~,
reduced_motion~,
),
foreground: option_interpolate_color(
self.foreground,
to.foreground,
progress~,
easing~,
reduced_motion~,
),
}
}
///|
pub fn TransitionSpec::new(
from~ : TransitionStyle = TransitionStyle::identity(),
to~ : TransitionStyle = TransitionStyle::identity(),
duration_ms~ : Double = 180.0,
easing~ : Easing = EaseInOut,
) -> TransitionSpec {
{ from, to, duration_ms: max_double(0.0, duration_ms), easing, spring: None, }
}
///|
/// Drive the transition's progress with spring physics instead of the fixed
/// duration + easing curve. `duration_ms` / `easing` stay as the fallback for
/// progress-based callers; the spring's settle estimate governs
/// `sample_elapsed` / `presence` timing.
pub fn TransitionSpec::with_spring(
self : TransitionSpec,
spring~ : SpringSpec,
) -> TransitionSpec {
{ ..self, spring: Some(spring), }
}
///|
pub fn TransitionSpec::fade(
from_opacity~ : Double = 0.0,
to_opacity~ : Double = 1.0,
duration_ms~ : Double = 180.0,
easing~ : Easing = EaseInOut,
) -> TransitionSpec {
TransitionSpec::new(
from=TransitionStyle::identity().with_opacity(from_opacity),
to=TransitionStyle::identity().with_opacity(to_opacity),
duration_ms~,
easing~,
)
}
///|
pub fn TransitionSpec::slide(
from_offset~ : Point,
to_offset~ : Point = Point::new(x=0.0, y=0.0),
duration_ms~ : Double = 180.0,
easing~ : Easing = EaseInOut,
) -> TransitionSpec {
TransitionSpec::new(
from=TransitionStyle::identity().with_offset(
dx=from_offset.x,
dy=from_offset.y,
),
to=TransitionStyle::identity().with_offset(dx=to_offset.x, dy=to_offset.y),
duration_ms~,
easing~,
)
}
///|
pub fn TransitionSpec::scale(
from_scale~ : Double = 0.96,
to_scale~ : Double = 1.0,
duration_ms~ : Double = 180.0,
easing~ : Easing = EaseInOut,
) -> TransitionSpec {
TransitionSpec::new(
from=TransitionStyle::identity().with_scale(sx=from_scale, sy=from_scale),
to=TransitionStyle::identity().with_scale(sx=to_scale, sy=to_scale),
duration_ms~,
easing~,
)
}
///|
pub fn TransitionSpec::sample(
self : TransitionSpec,
progress : Double,
reduced_motion~ : Bool = false,
) -> TransitionSample {
let resolved = transition_resolved_progress(progress, reduced_motion~)
{
progress: resolved,
style: self.from.interpolate(
self.to,
progress=resolved,
easing=self.easing,
reduced_motion=false,
),
present: true,
}
}
///|
pub fn TransitionSpec::sample_elapsed(
self : TransitionSpec,
elapsed_ms : Double,
reduced_motion~ : Bool = false,
) -> TransitionSample {
let elapsed_progress = match self.spring {
Some(spring) =>
if reduced_motion {
1.0
} else {
spring_progress(spring, elapsed_ms)
}
None => animation_progress(elapsed_ms, self.duration_ms)
}
self.sample(elapsed_progress, reduced_motion~)
}
///|
pub fn TransitionSpec::presence(
self : TransitionSpec,
present~ : Bool,
progress~ : Double,
reduced_motion~ : Bool = false,
) -> TransitionSample {
let resolved = transition_resolved_progress(progress, reduced_motion~)
let style = if present {
self.from.interpolate(
self.to,
progress=resolved,
easing=self.easing,
reduced_motion=false,
)
} else {
self.to.interpolate(
self.from,
progress=resolved,
easing=self.easing,
reduced_motion=false,
)
}
{ progress: resolved, style, present: present || resolved < 1.0, }
}
///|
fn option_interpolate_color(
from : Color?,
to : Color?,
progress~ : Double,
easing~ : Easing,
reduced_motion~ : Bool,
) -> Color? {
match (from, to) {
(Some(start), Some(end)) =>
Some(
animated_color(from=start, to=end, progress~, easing~, reduced_motion~),
)
(None, Some(end)) => Some(end)
(Some(start), None) => Some(start)
(None, None) => None
}
}
///|
fn transition_resolved_progress(
progress : Double,
reduced_motion~ : Bool,
) -> Double {
if reduced_motion {
1.0
} else {
clamp_double(progress, 0.0, 1.0)
}
}
///|
fn animation_progress(elapsed_ms : Double, duration_ms : Double) -> Double {
if duration_ms <= 0.0 {
1.0
} else {
clamp_double(elapsed_ms / duration_ms, 0.0, 1.0)
}
}
///|
/// Physical spring parameterization: `damping_ratio` (ζ; below 1 bounces,
/// 1 is critically damped, above 1 eases without overshoot) and `duration_ms`
/// as the nominal settle estimate that fixes the natural frequency. Immutable;
/// the closed-form curve and the stateful solver both read from it.
#derive(Eq, Debug, ToJson)
pub(all) struct SpringSpec {
damping_ratio : Double
duration_ms : Double
}
///|
pub extend SpringSpec with Debug::{to_repr}
///|
pub extend SpringSpec with Eq::{not_equal, equal}
///|
pub extend SpringSpec with ToJson::{to_json}
///|
pub fn SpringSpec::new(
damping_ratio~ : Double = 1.0,
duration_ms~ : Double = 220.0,
) -> SpringSpec {
{
damping_ratio: max_double(damping_ratio, 0.05),
duration_ms: max_double(duration_ms, 1.0),
}
}
///|
/// Soft settle with a light bounce; good for presence and card motion.
pub fn SpringSpec::gentle() -> SpringSpec {
SpringSpec::new(damping_ratio=0.85, duration_ms=260.0)
}
///|
/// Critically damped and fast; no overshoot, quick settle.
pub fn SpringSpec::snappy() -> SpringSpec {
SpringSpec::new(damping_ratio=1.0, duration_ms=200.0)
}
///|
/// Playful underdamped motion with a visible overshoot.
pub fn SpringSpec::bouncy() -> SpringSpec {
SpringSpec::new(damping_ratio=0.6, duration_ms=320.0)
}
///|
/// Envelope settle factor: the reference spring is ~99.9% settled after
/// `6.6 / ω` seconds, which fixes ω from the requested duration.
fn spring_settle_factor() -> Double {
6.6
}
///|
fn SpringSpec::omega(self : SpringSpec) -> Double {
spring_settle_factor() / max_double(self.duration_ms / 1000.0, 0.01)
}
///|
/// Closed-form normalized unit-displacement spring from rest toward 1.
/// ζ ≥ 1 curves are monotone; ζ < 1 overshoots past 1 and returns. Samples
/// inside the settle threshold snap to exactly 1.0 so callers can stop
/// requesting frames at a deterministic time.
pub fn spring_progress(spec : SpringSpec, elapsed_ms : Double) -> Double {
let t = max_double(elapsed_ms, 0.0) / 1000.0
let omega = spec.omega()
let zeta = spec.damping_ratio
let value = if zeta < 1.0 {
let damped = omega * (1.0 - zeta * zeta).sqrt()
1.0 -
@math.exp(-zeta * omega * t) *
(@math.cos(damped * t) + zeta * omega / damped * @math.sin(damped * t))
} else if zeta == 1.0 {
1.0 - (1.0 + omega * t) * @math.exp(-omega * t)
} else {
let spread = (zeta * zeta - 1.0).sqrt()
let slow_root = -omega * (zeta - spread)
let fast_root = -omega * (zeta + spread)
1.0 -
(
fast_root * @math.exp(slow_root * t) -
slow_root * @math.exp(fast_root * t)
) /
(fast_root - slow_root)
}
if (1.0 - value).abs() < 0.001 {
1.0
} else {
value
}
}
///|
/// Stateful, interruptible spring for app-driven motion. `velocity` carries
/// across `advance` calls, so retargeting mid-flight hands the current speed
/// to the new approach instead of restarting from rest.
#derive(Eq, Debug, ToJson)
pub(all) struct SpringState {
priv value : Double
priv velocity : Double
}
///|
pub extend SpringState with Debug::{to_repr}
///|
pub extend SpringState with Eq::{not_equal, equal}
///|
pub extend SpringState with ToJson::{to_json}
///|
pub fn SpringState::new(value~ : Double = 0.0) -> SpringState {
{ value, velocity: 0.0, }
}
///|
pub fn SpringState::value(self : SpringState) -> Double {
self.value
}
///|
pub fn SpringState::velocity(self : SpringState) -> Double {
self.velocity
}
///|
/// Advance one paint frame toward `target` with semi-implicit Euler, fixed
/// 1/120 s sub-steps so a given delta integrates identically regardless of
/// display refresh rate.
pub fn SpringState::advance(
self : SpringState,
target~ : Double,
frame~ : Frame,
spec~ : SpringSpec,
) -> SpringState {
let mut remaining = max_double(frame.delta_ms, 0.0) / 1000.0
let mut value = self.value
let mut velocity = self.velocity
let omega = spec.omega()
let zeta = spec.damping_ratio
let substep = 1.0 / 120.0
while remaining > 0.0 {
let dt = if remaining < substep { remaining } else { substep }
let acceleration = omega * omega * (target - value) -
2.0 * zeta * omega * velocity
velocity += acceleration * dt
value += velocity * dt
remaining -= dt
}
if (target - value).abs() < 0.001 && velocity.abs() < 0.01 {
{ value: target, velocity: 0.0, }
} else {
{ value, velocity, }
}
}
///|
pub fn SpringState::settled(self : SpringState, target~ : Double) -> Bool {
(target - self.value).abs() < 0.001 && self.velocity.abs() < 0.01
}