git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

src/ramp.rs (8.3K)

  1 // Speed-ramp evaluation for duration-based camera transitions.
  2 //
  3 // The input is the DE-wide ramp spec string written by cce-ui's Ramp widget
  4 // (`format_ramp_spec`): `"linear;0.000:0.100,0.500:1.000,1.000:0.050"` —
  5 // keys are `time:speed` pairs in [0,1]², and the `smooth` head draws a
  6 // monotone cubic through the keys instead of straight segments. The tiny
  7 // parser and the interpolation are MIRRORED from cce-ui
  8 // (`layout::sample_ramp_keys`; this crate stays dependency-minimal), so the
  9 // curve sculpted in the widget is exactly the curve evaluated here — keep
 10 // the two in step.
 11 //
 12 // The ramp is a SPEED profile over normalized time. Construction integrates
 13 // it once into a cumulative-progress table normalized to end at exactly 1,
 14 // so any profile arrives precisely at the target; zero-speed segments read
 15 // as dwell. An (effectively) all-zero ramp yields `None` — callers fall
 16 // back to their non-ramp animation.
 17 
 18 /// Number of integration samples. Progress lookups interpolate linearly
 19 /// between samples, so this bounds the timing error of a 60Hz animation to
 20 /// well under a frame.
 21 const SAMPLES: usize = 256;
 22 
 23 /// Parse a ramp spec string into `(keys, smooth)`; `None` for anything that
 24 /// doesn't yield at least two keys. Mirrors cce-ui's `parse_ramp_spec`.
 25 pub fn parse_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
 26     let (head, body) = spec.split_once(';')?;
 27     let smooth = head.trim() == "smooth";
 28     let mut keys = Vec::new();
 29     for part in body.split(',') {
 30         let (p, v) = part.split_once(':')?;
 31         keys.push((
 32             p.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
 33             v.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
 34         ));
 35     }
 36     if keys.len() < 2 {
 37         return None;
 38     }
 39     keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
 40     Some((keys, smooth))
 41 }
 42 
 43 /// The ramp's value at `t` — endpoint-clamped; `smooth` is a monotone cubic
 44 /// through the keys (Fritsch–Butland tangents, zero at the ends and at local
 45 /// extrema, cubic Hermite segments), else straight segments. Mirrors cce-ui's
 46 /// `layout::sample_ramp_keys` exactly; a two-key smooth ramp is the plain
 47 /// smoothstep.
 48 fn value_at(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
 49     if keys.is_empty() {
 50         return 0.0;
 51     }
 52     if t <= keys[0].0 {
 53         return keys[0].1;
 54     }
 55     if t >= keys[keys.len() - 1].0 {
 56         return keys[keys.len() - 1].1;
 57     }
 58     for i in 0..keys.len() - 1 {
 59         let ((x0, y0), (x1, y1)) = (keys[i], keys[i + 1]);
 60         if t < x0 || t > x1 {
 61             continue;
 62         }
 63         let h = x1 - x0;
 64         if h.abs() < 0.0001 {
 65             return y0;
 66         }
 67         let s = (t - x0) / h;
 68         if !smooth {
 69             return y0 + (y1 - y0) * s;
 70         }
 71         let (m0, m1) = (key_tangent(keys, i), key_tangent(keys, i + 1));
 72         let (s2, s3) = (s * s, s * s * s);
 73         let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
 74         let h10 = s3 - 2.0 * s2 + s;
 75         let h01 = -2.0 * s3 + 3.0 * s2;
 76         let h11 = s3 - s2;
 77         return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
 78     }
 79     keys[0].1
 80 }
 81 
 82 /// Tangent at key `i` for `value_at`'s smooth mode — mirrors cce-ui's
 83 /// `layout::ramp_key_tangent`.
 84 fn key_tangent(keys: &[(f32, f32)], i: usize) -> f32 {
 85     if i == 0 || i + 1 >= keys.len() {
 86         return 0.0;
 87     }
 88     let ((xp, yp), (x, y), (xn, yn)) = (keys[i - 1], keys[i], keys[i + 1]);
 89     let (h0, h1) = (x - xp, xn - x);
 90     if h0 <= 0.0001 || h1 <= 0.0001 {
 91         return 0.0;
 92     }
 93     let (d0, d1) = ((y - yp) / h0, (yn - y) / h1);
 94     if d0 * d1 <= 0.0 {
 95         return 0.0;
 96     }
 97     let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
 98     (w0 + w1) / (w0 / d0 + w1 / d1)
 99 }
100 
101 /// A speed profile integrated into a normalized progress curve.
102 #[derive(Debug, Clone)]
103 pub struct SpeedRamp {
104     /// Cumulative progress at SAMPLES+1 evenly spaced times:
105     /// `table[0] == 0.0`, `table[SAMPLES] == 1.0`.
106     table: Vec<f64>,
107 }
108 
109 impl SpeedRamp {
110     /// Build from a spec string; `None` if the spec doesn't parse or the
111     /// speed integrates to (effectively) zero.
112     pub fn from_spec(spec: &str) -> Option<SpeedRamp> {
113         let (keys, smooth) = parse_spec(spec)?;
114         // Midpoint rule per sample interval.
115         let mut table = Vec::with_capacity(SAMPLES + 1);
116         table.push(0.0);
117         let mut acc = 0.0f64;
118         for i in 0..SAMPLES {
119             let mid = (i as f32 + 0.5) / SAMPLES as f32;
120             acc += value_at(&keys, smooth, mid).max(0.0) as f64;
121             table.push(acc);
122         }
123         let total = table[SAMPLES];
124         if total < 1e-6 {
125             return None;
126         }
127         for v in table.iter_mut() {
128             *v /= total;
129         }
130         Some(SpeedRamp { table })
131     }
132 
133     /// Progress through the transition at normalized time `t` (clamped to
134     /// `[0,1]`): 0 at start, exactly 1 at the end, monotonic.
135     pub fn progress(&self, t: f64) -> f64 {
136         if t <= 0.0 {
137             return 0.0;
138         }
139         if t >= 1.0 {
140             return 1.0;
141         }
142         let x = t * SAMPLES as f64;
143         let i = x.floor() as usize;
144         let frac = x - i as f64;
145         self.table[i] * (1.0 - frac) + self.table[i + 1] * frac
146     }
147 }
148 
149 #[cfg(test)]
150 mod tests {
151     use super::*;
152 
153     #[test]
154     fn constant_speed_is_linear_progress() {
155         let r = SpeedRamp::from_spec("linear;0.0:1.0,1.0:1.0").unwrap();
156         for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
157             assert!((r.progress(t) - t).abs() < 1e-3, "t={t}");
158         }
159     }
160 
161     #[test]
162     fn endpoints_are_exact() {
163         let r = SpeedRamp::from_spec("smooth;0.0:0.1,0.4:1.0,1.0:0.05").unwrap();
164         assert_eq!(r.progress(0.0), 0.0);
165         assert_eq!(r.progress(1.0), 1.0);
166         assert_eq!(r.progress(-0.5), 0.0);
167         assert_eq!(r.progress(2.0), 1.0);
168     }
169 
170     #[test]
171     fn slow_start_covers_less_ground_early() {
172         // Speed ramps 0 → 1: the first half of the time covers well under
173         // half the distance.
174         let r = SpeedRamp::from_spec("linear;0.0:0.0,1.0:1.0").unwrap();
175         assert!(r.progress(0.5) < 0.3, "got {}", r.progress(0.5));
176     }
177 
178     #[test]
179     fn monotonic_even_with_dwell() {
180         // A zero-speed plateau mid-ramp: progress holds but never regresses.
181         let r = SpeedRamp::from_spec("linear;0.0:1.0,0.4:0.0,0.6:0.0,1.0:1.0").unwrap();
182         let mut last = 0.0;
183         for i in 0..=100 {
184             let p = r.progress(i as f64 / 100.0);
185             assert!(p >= last - 1e-12);
186             last = p;
187         }
188         // The plateau really dwells: progress barely moves across it.
189         assert!((r.progress(0.58) - r.progress(0.42)).abs() < 0.02);
190     }
191 
192     #[test]
193     fn zero_ramp_is_rejected() {
194         assert!(SpeedRamp::from_spec("linear;0.0:0.0,1.0:0.0").is_none());
195         assert!(SpeedRamp::from_spec("garbage").is_none());
196         assert!(SpeedRamp::from_spec("linear;0.5:1.0").is_none());
197     }
198 
199     #[test]
200     fn smooth_matches_widget_semantics() {
201         // One segment 0→1: exactly the smoothstep (zero end tangents), so
202         // the midpoint is 0.5 and the curve is steeper mid-segment than
203         // linear at the edges.
204         let (keys, smooth) = parse_spec("smooth;0.0:0.0,1.0:1.0").unwrap();
205         assert!(smooth);
206         for i in 0..=10 {
207             let t = i as f32 / 10.0;
208             assert!((value_at(&keys, true, t) - t * t * (3.0 - 2.0 * t)).abs() < 1e-6);
209         }
210         assert!(value_at(&keys, true, 0.25) < 0.25);
211         assert!(value_at(&keys, true, 0.75) > 0.75);
212     }
213 
214     #[test]
215     fn smooth_mirrors_cce_ui_sample_ramp_keys() {
216         // Pinned samples of cce-ui's `layout::sample_ramp_keys` on the
217         // overview ramp and a six-key monotone profile: if either copy
218         // drifts, this and the cce-ui test disagree.
219         let (keys, _) = parse_spec("smooth;0.000:0.150,0.400:1.000,1.000:0.100").unwrap();
220         assert!((value_at(&keys, true, 0.4) - 1.0).abs() < 1e-6);
221         assert!(value_at(&keys, true, 0.39) > 0.99, "flat at the peak");
222         let (keys, _) = parse_spec("smooth;0:0,0.15:0.45,0.35:0.7,0.55:0.78,0.75:0.85,1:1").unwrap();
223         let mut last = -1.0f32;
224         for i in 0..=200 {
225             let v = value_at(&keys, true, i as f32 / 200.0);
226             assert!(v >= last - 1e-6, "monotone");
227             last = v;
228         }
229         let dv = (value_at(&keys, true, 0.355) - value_at(&keys, true, 0.345)) / 0.01;
230         assert!(dv > 0.3, "a real slope at an interior key, not the old zero: {dv}");
231     }
232 }