///|
/// Computed-value interpolation for CSS animations.
///
/// CSS animations interpolate *computed values*; the result is later resolved
/// to a used value by the layout engine (exactly like a static computed value).
/// This module therefore stays layout-independent: it interpolates already
/// computed values (px lengths, percentages, numbers, colors) and leaves
/// used-value resolution (% -> px, `auto`, intrinsic sizing) to the consumer.
///|
/// A computed value that can take part in animation interpolation. Anything not
/// representable here (or interpolated between mismatched kinds) animates
/// discretely (a 50% jump).
pub(all) enum AnimatableValue {
Number(Double)
Length(Double) // px
Percent(Double) // ratio: 0.5 == 50%
Color(@values.Color)
Transform(@style.Transform)
/// Non-interpolable / unsupported computed value; animates discretely.
Discrete(String)
} derive(Eq)
///|
pub impl Show for AnimatableValue with output(self, logger) {
match self {
Number(v) => logger.write_string("Number(\{v})")
Length(v) => logger.write_string("Length(\{v})")
Percent(v) => logger.write_string("Percent(\{v})")
Color(c) => {
logger.write_string("Color(")
c.output(logger)
logger.write_string(")")
}
Transform(t) => {
logger.write_string("Transform(")
t.output(logger)
logger.write_string(")")
}
Discrete(s) => logger.write_string("Discrete(\{s})")
}
}
///|
fn lerp(a : Double, b : Double, t : Double) -> Double {
a + (b - a) * t
}
///|
/// Round to the nearest integer and clamp to the 0-255 color-channel range.
fn lerp_channel(a : Int, b : Int, t : Double) -> Int {
let v = lerp(a.to_double(), b.to_double(), t)
let r = (v + 0.5).to_int()
if r < 0 {
0
} else if r > 255 {
255
} else {
r
}
}
///|
/// Interpolate two colors in sRGB. Alpha is interpolated linearly.
fn interpolate_color(
a : @values.Color,
b : @values.Color,
t : Double,
) -> @values.Color {
@values.Color::rgba(
lerp_channel(a.r, b.r, t),
lerp_channel(a.g, b.g, t),
lerp_channel(a.b, b.b, t),
lerp(a.a, b.a, t),
)
}
///|
/// Interpolate between two computed values at eased progress `t` in [0, 1].
/// Matching numeric kinds interpolate linearly; colors interpolate in sRGB;
/// any mismatched or non-interpolable pair animates discretely (jumps at 0.5).
pub fn interpolate(
from : AnimatableValue,
to : AnimatableValue,
t : Double,
) -> AnimatableValue {
match (from, to) {
(Number(a), Number(b)) => Number(lerp(a, b, t))
(Length(a), Length(b)) => Length(lerp(a, b, t))
(Percent(a), Percent(b)) => Percent(lerp(a, b, t))
(Color(a), Color(b)) => Color(interpolate_color(a, b, t))
(Transform(a), Transform(b)) =>
match interpolate_transform(a, b, t) {
Some(tr) => Transform(tr)
None => if t < 0.5 { from } else { to }
}
_ => if t < 0.5 { from } else { to }
}
}
///|
/// Interpolate one `translate` component (px length or percentage). Mismatched
/// kinds (length vs percentage) cannot interpolate without a box basis, so they
/// animate discretely.
fn interpolate_translate(
a : @style.TranslateValue,
b : @style.TranslateValue,
t : Double,
) -> @style.TranslateValue {
match (a, b) {
(@style.TranslateValue::Length(x), @style.TranslateValue::Length(y)) =>
@style.TranslateValue::Length(lerp(x, y, t))
(@style.TranslateValue::Percent(x), @style.TranslateValue::Percent(y)) =>
@style.TranslateValue::Percent(lerp(x, y, t))
_ => if t < 0.5 { a } else { b }
}
}
///|
/// Interpolate two transforms component-wise at the computed-value level
/// (translate / scale / rotate / skew). Two explicit `matrix()` transforms are
/// interpolated component-wise. A mismatch between a matrix and a decomposed
/// transform returns None (the caller animates discretely) — full matrix
/// decomposition is a later phase.
fn interpolate_transform(
a : @style.Transform,
b : @style.Transform,
t : Double,
) -> @style.Transform? {
// 3D matrices interpolate via 3D decompose / quaternion slerp / recompose.
// A matrix3d mixed with a non-matrix3d transform falls back to discrete.
if a.has_matrix3d() || b.has_matrix3d() {
if a.has_matrix3d() && b.has_matrix3d() {
match interpolate_matrix3d(a.matrix3d, b.matrix3d, t) {
Some(m) =>
return Some({
..@style.Transform::none(),
has_matrix: true,
matrix_a: m[0],
matrix_b: m[1],
matrix_c: m[4],
matrix_d: m[5],
matrix_e: m[12],
matrix_f: m[13],
matrix3d: m,
})
None => return None
}
}
return None
}
// Per spec, mismatched transforms are decomposed and interpolated. Here a
// matrix() operand is decomposed to the (translate, scale, rotate, skew) form
// so it can interpolate component-wise with the other operand (including the
// both-matrices case, which a naive component lerp would get wrong for
// rotation).
let da = if a.has_matrix { decompose_2d(a) } else { a }
let db = if b.has_matrix { decompose_2d(b) } else { b }
Some({
..da,
translate_x: interpolate_translate(da.translate_x, db.translate_x, t),
translate_y: interpolate_translate(da.translate_y, db.translate_y, t),
scale_x: lerp(da.scale_x, db.scale_x, t),
scale_y: lerp(da.scale_y, db.scale_y, t),
rotate_degrees: lerp(da.rotate_degrees, db.rotate_degrees, t),
skew_x_degrees: lerp(da.skew_x_degrees, db.skew_x_degrees, t),
skew_y_degrees: lerp(da.skew_y_degrees, db.skew_y_degrees, t),
})
}
///|
/// Decompose a 2D affine `matrix(a,b,c,d,e,f)` transform into the
/// (translate, scale, rotate, skewX) form (the "unmatrix" 2D algorithm), so it
/// can interpolate component-wise. skewY is not separately recoverable from a 2D
/// matrix and is reported as 0.
fn decompose_2d(m : @style.Transform) -> @style.Transform {
let mut row0x = m.matrix_a
let mut row0y = m.matrix_b
let mut row1x = m.matrix_c
let mut row1y = m.matrix_d
let det = row0x * row1y - row0y * row1x
let mut scale_x = (row0x * row0x + row0y * row0y).sqrt()
if scale_x == 0.0 {
// Degenerate first row; nothing meaningful to decompose.
return {
..@style.Transform::none(),
translate_x: @style.TranslateValue::Length(m.matrix_e),
translate_y: @style.TranslateValue::Length(m.matrix_f),
}
}
row0x = row0x / scale_x
row0y = row0y / scale_x
let mut shear = row0x * row1x + row0y * row1y
row1x = row1x - row0x * shear
row1y = row1y - row0y * shear
let scale_y = (row1x * row1x + row1y * row1y).sqrt()
if scale_y != 0.0 {
row1x = row1x / scale_y
row1y = row1y / scale_y
shear = shear / scale_y
}
// A negative determinant means a reflection; fold it into scale_x.
if det < 0.0 {
scale_x = -scale_x
row0x = -row0x
row0y = -row0y
}
let deg = 180.0 / @math.PI
{
..@style.Transform::none(),
translate_x: @style.TranslateValue::Length(m.matrix_e),
translate_y: @style.TranslateValue::Length(m.matrix_f),
scale_x,
scale_y,
rotate_degrees: @math.atan2(row0y, row0x) * deg,
skew_x_degrees: @math.atan(shear) * deg,
skew_y_degrees: 0.0,
}
}
// ---- Oklab color interpolation (CSS Color 4) ----
///|
/// sRGB 0-255 channel to linear-light [0, 1].
fn srgb_to_linear(c : Int) -> Double {
let x = c.to_double() / 255.0
if x <= 0.04045 {
x / 12.92
} else {
@math.pow((x + 0.055) / 1.055, 2.4)
}
}
///|
/// Linear-light [0, 1] to sRGB 0-255 channel (rounded, clamped).
fn linear_to_srgb(x : Double) -> Int {
let c = if x <= 0.0031308 {
12.92 * x
} else {
1.055 * @math.pow(x, 1.0 / 2.4) - 0.055
}
let v = (c * 255.0 + 0.5).to_int()
if v < 0 {
0
} else if v > 255 {
255
} else {
v
}
}
///|
/// Interpolate two colors in the Oklab color space (CSS Color 4 default for
/// `` interpolation). Alpha is interpolated linearly in [0, 1].
pub fn interpolate_color_oklab(
a : @values.Color,
b : @values.Color,
t : Double,
) -> @values.Color {
let (l1, a1, b1) = srgb_to_oklab(a)
let (l2, a2, b2) = srgb_to_oklab(b)
let (r, g, bl) = oklab_to_srgb(
lerp(l1, l2, t),
lerp(a1, a2, t),
lerp(b1, b2, t),
)
@values.Color::rgba(r, g, bl, lerp(a.a, b.a, t))
}
///|
fn srgb_to_oklab(c : @values.Color) -> (Double, Double, Double) {
let r = srgb_to_linear(c.r)
let g = srgb_to_linear(c.g)
let b = srgb_to_linear(c.b)
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
let l_ = @math.cbrt(l)
let m_ = @math.cbrt(m)
let s_ = @math.cbrt(s)
(
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
)
}
///|
fn oklab_to_srgb(l : Double, a : Double, b : Double) -> (Int, Int, Int) {
let l_ = l + 0.3963377774 * a + 0.2158037573 * b
let m_ = l - 0.1055613458 * a - 0.0638541728 * b
let s_ = l - 0.0894841775 * a - 1.2914855480 * b
let l3 = l_ * l_ * l_
let m3 = m_ * m_ * m_
let s3 = s_ * s_ * s_
let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3
let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3
let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3
(linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(bl))
}
// ---- Additional color spaces (CSS Color 4): oklch, lab, lch ----
///|
/// Color space used for `` interpolation.
pub(all) enum ColorSpace {
Srgb
Oklab
Oklch
Lab
Lch
} derive(Debug, Eq)
///|
/// Interpolate a hue (radians) along the shortest arc.
fn lerp_hue(h1 : Double, h2 : Double, t : Double) -> Double {
let two_pi = 2.0 * @math.PI
let mut dh = h2 - h1
while dh > @math.PI {
dh = dh - two_pi
}
while dh < -@math.PI {
dh = dh + two_pi
}
h1 + dh * t
}
///|
/// Interpolate two colors in the given color space. Rectangular spaces (srgb,
/// oklab, lab) interpolate channels linearly; polar spaces (oklch, lch)
/// interpolate lightness/chroma linearly and hue along the shortest arc. Alpha
/// is always interpolated linearly.
pub fn interpolate_color_in(
space : ColorSpace,
a : @values.Color,
b : @values.Color,
t : Double,
) -> @values.Color {
let alpha = lerp(a.a, b.a, t)
match space {
Srgb => interpolate_color(a, b, t)
Oklab => interpolate_color_oklab(a, b, t)
Lab => {
let (l1, a1, b1) = srgb_to_lab(a)
let (l2, a2, b2) = srgb_to_lab(b)
let (r, g, bl) = lab_to_srgb(lerp(l1, l2, t), lerp(a1, a2, t), lerp(b1, b2, t))
@values.Color::rgba(r, g, bl, alpha)
}
Oklch => {
let (l1, c1, h1) = srgb_to_oklch(a)
let (l2, c2, h2) = srgb_to_oklch(b)
let (oa, ob) = lch_to_ab(lerp(c1, c2, t), lerp_hue(h1, h2, t))
let (r, g, bl) = oklab_to_srgb(lerp(l1, l2, t), oa, ob)
@values.Color::rgba(r, g, bl, alpha)
}
Lch => {
let (l1, c1, h1) = srgb_to_lch(a)
let (l2, c2, h2) = srgb_to_lch(b)
let (la, lb) = lch_to_ab(lerp(c1, c2, t), lerp_hue(h1, h2, t))
let (r, g, bl) = lab_to_srgb(lerp(l1, l2, t), la, lb)
@values.Color::rgba(r, g, bl, alpha)
}
}
}
///|
/// Convert a rectangular (a, b) pair from a chroma/hue (hue in radians).
fn lch_to_ab(c : Double, h : Double) -> (Double, Double) {
(c * @math.cos(h), c * @math.sin(h))
}
///|
fn srgb_to_oklch(col : @values.Color) -> (Double, Double, Double) {
let (l, a, b) = srgb_to_oklab(col)
(l, (a * a + b * b).sqrt(), @math.atan2(b, a))
}
///|
/// sRGB color to CIE Lab (D65).
fn srgb_to_lab(col : @values.Color) -> (Double, Double, Double) {
let r = srgb_to_linear(col.r)
let g = srgb_to_linear(col.g)
let b = srgb_to_linear(col.b)
let x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
let z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
let xn = 0.95047
let yn = 1.0
let zn = 1.08883
let fx = lab_f(x / xn)
let fy = lab_f(y / yn)
let fz = lab_f(z / zn)
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
}
///|
fn srgb_to_lch(col : @values.Color) -> (Double, Double, Double) {
let (l, a, b) = srgb_to_lab(col)
(l, (a * a + b * b).sqrt(), @math.atan2(b, a))
}
///|
fn lab_f(t : Double) -> Double {
let eps = 216.0 / 24389.0
if t > eps {
@math.cbrt(t)
} else {
(24389.0 / 27.0 * t + 16.0) / 116.0
}
}
///|
/// CIE Lab (D65) to sRGB 0-255.
fn lab_to_srgb(l : Double, a : Double, b : Double) -> (Int, Int, Int) {
let fy = (l + 16.0) / 116.0
let fx = fy + a / 500.0
let fz = fy - b / 200.0
let eps = 216.0 / 24389.0
let kappa = 24389.0 / 27.0
let fx3 = fx * fx * fx
let fz3 = fz * fz * fz
let xr = if fx3 > eps { fx3 } else { (116.0 * fx - 16.0) / kappa }
let yr = if l > kappa * eps { fy * fy * fy } else { l / kappa }
let zr = if fz3 > eps { fz3 } else { (116.0 * fz - 16.0) / kappa }
let x = xr * 0.95047
let y = yr * 1.0
let z = zr * 1.08883
let r = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
let g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
let bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
(linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(bl))
}
///|
/// One typed keyframe: an offset in [0, 1], the computed value at that offset,
/// and the easing governing interpolation *to* the next keyframe.
pub(all) struct AnimatableKeyframe {
offset : Double
value : AnimatableValue
easing : @values.Easing
}
///|
pub fn AnimatableKeyframe::new(
offset : Double,
value : AnimatableValue,
) -> AnimatableKeyframe {
{ offset, value, easing: @values.Easing::Linear }
}
///|
pub fn AnimatableKeyframe::with_easing(
offset : Double,
value : AnimatableValue,
easing : @values.Easing,
) -> AnimatableKeyframe {
{ offset, value, easing }
}
///|
/// A typed keyframe effect: keyframes (sorted by offset) plus timing. Mirrors
/// `NumericKeyframeEffect` but interpolates `AnimatableValue` computed values.
pub(all) struct AnimatableKeyframeEffect {
keyframes : Array[AnimatableKeyframe]
timing : EffectTiming
}
///|
/// Build a typed keyframe effect. Returns None if there are no keyframes.
/// Keyframes are assumed to be sorted by non-decreasing offset.
pub fn AnimatableKeyframeEffect::new(
keyframes : Array[AnimatableKeyframe],
timing : EffectTiming,
) -> AnimatableKeyframeEffect? {
if keyframes.length() == 0 {
None
} else {
Some({ keyframes, timing })
}
}
///|
/// Sample the computed value at normalized progress `progress` in [0, 1] within
/// one iteration. Values before the first / after the last keyframe clamp to the
/// endpoint value.
pub fn AnimatableKeyframeEffect::sample_progress(
self : AnimatableKeyframeEffect,
progress : Double,
) -> AnimatableValue {
let kfs = self.keyframes
let p = if progress < 0.0 {
0.0
} else if progress > 1.0 {
1.0
} else {
progress
}
let first = kfs[0]
if p <= first.offset {
return first.value
}
let last = kfs[kfs.length() - 1]
if p >= last.offset {
return last.value
}
for i = 0; i < kfs.length() - 1; i = i + 1 {
let start = kfs[i]
let end = kfs[i + 1]
if p >= start.offset && p <= end.offset {
let span = end.offset - start.offset
if span <= 0.0 {
// Coincident offsets: jump to the later keyframe's value.
return end.value
}
let local_progress = (p - start.offset) / span
let eased = start.easing.sample(local_progress)
return interpolate(start.value, end.value, eased)
}
}
last.value
}
///|
/// Sample the computed value at `local_time` (ms on the effect's local
/// timeline). Returns None when the timing model produces no active value
/// (e.g. a non-filled before/after phase).
pub fn AnimatableKeyframeEffect::sample(
self : AnimatableKeyframeEffect,
local_time : Double,
) -> AnimatableValue? {
let computed = self.timing.at(local_time)
match computed.transformed_progress {
Some(p) => Some(self.sample_progress(p))
None => None
}
}