Skip to main content

guise/anim/
value.rs

1//! The values a motion can carry between two states.
2//!
3//! Everything an animation moves is either a number or a colour, so that is
4//! the whole vocabulary: one `Copy` enum, one `lerp`. Keeping it closed (no
5//! boxed `dyn Animatable`) is what lets a sampled [`Frame`](super::Frame) be
6//! built and thrown away every frame without allocating per value.
7
8use gpui::{Hsla, Pixels, Rgba};
9
10/// A number or a colour, mid-interpolation.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum AnimValue {
13  Number(f32),
14  Color(Hsla),
15}
16
17impl AnimValue {
18  /// Interpolate toward `other`. Mixed kinds can't be blended, so the
19  /// destination wins outright — a mismatch is a programming error, not a
20  /// reason to panic mid-frame.
21  pub fn lerp(self, other: Self, t: f32) -> Self {
22    match (self, other) {
23      (AnimValue::Number(a), AnimValue::Number(b)) => AnimValue::Number(a + (b - a) * t),
24      (AnimValue::Color(a), AnimValue::Color(b)) => AnimValue::Color(lerp_hsla(a, b, t)),
25      (_, b) => b,
26    }
27  }
28
29  /// The number, or `0.0` for a colour.
30  pub fn number(self) -> f32 {
31    match self {
32      AnimValue::Number(v) => v,
33      AnimValue::Color(_) => 0.0,
34    }
35  }
36
37  pub fn color(self) -> Option<Hsla> {
38    match self {
39      AnimValue::Color(c) => Some(c),
40      AnimValue::Number(_) => None,
41    }
42  }
43
44  pub fn is_color(self) -> bool {
45    matches!(self, AnimValue::Color(_))
46  }
47}
48
49impl From<f32> for AnimValue {
50  fn from(v: f32) -> Self {
51    AnimValue::Number(v)
52  }
53}
54
55impl From<f64> for AnimValue {
56  fn from(v: f64) -> Self {
57    AnimValue::Number(v as f32)
58  }
59}
60
61impl From<i32> for AnimValue {
62  fn from(v: i32) -> Self {
63    AnimValue::Number(v as f32)
64  }
65}
66
67impl From<Pixels> for AnimValue {
68  fn from(v: Pixels) -> Self {
69    AnimValue::Number(f32::from(v))
70  }
71}
72
73impl From<Hsla> for AnimValue {
74  fn from(v: Hsla) -> Self {
75    AnimValue::Color(v)
76  }
77}
78
79impl From<Rgba> for AnimValue {
80  fn from(v: Rgba) -> Self {
81    AnimValue::Color(v.into())
82  }
83}
84
85/// Blend two colours the short way around the hue wheel.
86///
87/// Component-wise lerp on HSL sends red → cyan the long way through green;
88/// taking the shorter arc is what makes a hover colour change look like a
89/// crossfade instead of a rainbow sweep. Fully transparent endpoints carry
90/// no meaningful hue, so they borrow the other end's.
91fn lerp_hsla(a: Hsla, b: Hsla, t: f32) -> Hsla {
92  let (ah, bh) = match (a.a <= 0.0, b.a <= 0.0) {
93    (true, false) => (b.h, b.h),
94    (false, true) => (a.h, a.h),
95    _ => (a.h, b.h),
96  };
97  let mut delta = bh - ah;
98  if delta > 0.5 {
99    delta -= 1.0;
100  } else if delta < -0.5 {
101    delta += 1.0;
102  }
103  let h = (ah + delta * t).rem_euclid(1.0);
104  Hsla {
105    h,
106    s: a.s + (b.s - a.s) * t,
107    l: a.l + (b.l - a.l) * t,
108    a: a.a + (b.a - a.a) * t,
109  }
110}
111
112#[cfg(test)]
113mod tests {
114  use super::*;
115
116  fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
117    Hsla { h, s, l, a }
118  }
119
120  #[test]
121  fn numbers_interpolate_linearly() {
122    let v = AnimValue::from(10.0).lerp(AnimValue::from(20.0), 0.25);
123    assert_eq!(v.number(), 12.5);
124  }
125
126  #[test]
127  fn hue_takes_the_short_way_round() {
128    // 0.9 -> 0.1 is 0.2 forward through the wrap, not 0.8 backward.
129    let a = hsla(0.9, 1.0, 0.5, 1.0);
130    let b = hsla(0.1, 1.0, 0.5, 1.0);
131    let mid = AnimValue::from(a).lerp(AnimValue::from(b), 0.5).color();
132    assert!((mid.unwrap().h - 0.0).abs() < 1e-5, "{mid:?}");
133  }
134
135  #[test]
136  fn transparent_endpoints_borrow_the_other_hue() {
137    let clear = hsla(0.0, 0.0, 0.0, 0.0);
138    let blue = hsla(0.6, 1.0, 0.5, 1.0);
139    let mid = AnimValue::from(clear)
140      .lerp(AnimValue::from(blue), 0.5)
141      .color()
142      .unwrap();
143    assert!((mid.h - 0.6).abs() < 1e-5, "{mid:?}");
144    assert!((mid.a - 0.5).abs() < 1e-5);
145  }
146
147  #[test]
148  fn mismatched_kinds_snap_to_the_destination() {
149    let v = AnimValue::from(1.0).lerp(AnimValue::from(hsla(0.5, 1.0, 0.5, 1.0)), 0.5);
150    assert!(v.is_color());
151  }
152}