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

src/ramp.rs (6.2K)

  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 parser
  7 // and the interpolation are cce-core's `ramp` (taken without its config
  8 // half, so this crate gains no KDL or JSON), the same functions cce-ui's
  9 // widget draws with, so the curve sculpted there is the curve evaluated here.
 10 // Until 2026-10-07 they were a hand-kept mirror of cce-ui's.
 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.
 25 pub use cce_core::ramp::parse_ramp_spec as parse_spec;
 26 /// The ramp's value at `t`: endpoint-clamped, a monotone cubic when `smooth`.
 27 use cce_core::ramp::sample_ramp_keys as value_at;
 28 
 29 /// A speed profile integrated into a normalized progress curve.
 30 #[derive(Debug, Clone)]
 31 pub struct SpeedRamp {
 32     /// Cumulative progress at SAMPLES+1 evenly spaced times:
 33     /// `table[0] == 0.0`, `table[SAMPLES] == 1.0`.
 34     table: Vec<f64>,
 35 }
 36 
 37 impl SpeedRamp {
 38     /// Build from a spec string; `None` if the spec doesn't parse or the
 39     /// speed integrates to (effectively) zero.
 40     pub fn from_spec(spec: &str) -> Option<SpeedRamp> {
 41         let (keys, smooth) = parse_spec(spec)?;
 42         // Midpoint rule per sample interval.
 43         let mut table = Vec::with_capacity(SAMPLES + 1);
 44         table.push(0.0);
 45         let mut acc = 0.0f64;
 46         for i in 0..SAMPLES {
 47             let mid = (i as f32 + 0.5) / SAMPLES as f32;
 48             acc += value_at(&keys, smooth, mid).max(0.0) as f64;
 49             table.push(acc);
 50         }
 51         let total = table[SAMPLES];
 52         if total < 1e-6 {
 53             return None;
 54         }
 55         for v in table.iter_mut() {
 56             *v /= total;
 57         }
 58         Some(SpeedRamp { table })
 59     }
 60 
 61     /// Progress through the transition at normalized time `t` (clamped to
 62     /// `[0,1]`): 0 at start, exactly 1 at the end, monotonic.
 63     pub fn progress(&self, t: f64) -> f64 {
 64         if t <= 0.0 {
 65             return 0.0;
 66         }
 67         if t >= 1.0 {
 68             return 1.0;
 69         }
 70         let x = t * SAMPLES as f64;
 71         let i = x.floor() as usize;
 72         let frac = x - i as f64;
 73         self.table[i] * (1.0 - frac) + self.table[i + 1] * frac
 74     }
 75 }
 76 
 77 #[cfg(test)]
 78 mod tests {
 79     use super::*;
 80 
 81     #[test]
 82     fn constant_speed_is_linear_progress() {
 83         let r = SpeedRamp::from_spec("linear;0.0:1.0,1.0:1.0").unwrap();
 84         for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
 85             assert!((r.progress(t) - t).abs() < 1e-3, "t={t}");
 86         }
 87     }
 88 
 89     #[test]
 90     fn endpoints_are_exact() {
 91         let r = SpeedRamp::from_spec("smooth;0.0:0.1,0.4:1.0,1.0:0.05").unwrap();
 92         assert_eq!(r.progress(0.0), 0.0);
 93         assert_eq!(r.progress(1.0), 1.0);
 94         assert_eq!(r.progress(-0.5), 0.0);
 95         assert_eq!(r.progress(2.0), 1.0);
 96     }
 97 
 98     #[test]
 99     fn slow_start_covers_less_ground_early() {
100         // Speed ramps 0 → 1: the first half of the time covers well under
101         // half the distance.
102         let r = SpeedRamp::from_spec("linear;0.0:0.0,1.0:1.0").unwrap();
103         assert!(r.progress(0.5) < 0.3, "got {}", r.progress(0.5));
104     }
105 
106     #[test]
107     fn monotonic_even_with_dwell() {
108         // A zero-speed plateau mid-ramp: progress holds but never regresses.
109         let r = SpeedRamp::from_spec("linear;0.0:1.0,0.4:0.0,0.6:0.0,1.0:1.0").unwrap();
110         let mut last = 0.0;
111         for i in 0..=100 {
112             let p = r.progress(i as f64 / 100.0);
113             assert!(p >= last - 1e-12);
114             last = p;
115         }
116         // The plateau really dwells: progress barely moves across it.
117         assert!((r.progress(0.58) - r.progress(0.42)).abs() < 0.02);
118     }
119 
120     #[test]
121     fn zero_ramp_is_rejected() {
122         assert!(SpeedRamp::from_spec("linear;0.0:0.0,1.0:0.0").is_none());
123         assert!(SpeedRamp::from_spec("garbage").is_none());
124         assert!(SpeedRamp::from_spec("linear;0.5:1.0").is_none());
125     }
126 
127     #[test]
128     fn smooth_matches_widget_semantics() {
129         // One segment 0→1: exactly the smoothstep (zero end tangents), so
130         // the midpoint is 0.5 and the curve is steeper mid-segment than
131         // linear at the edges.
132         let (keys, smooth) = parse_spec("smooth;0.0:0.0,1.0:1.0").unwrap();
133         assert!(smooth);
134         for i in 0..=10 {
135             let t = i as f32 / 10.0;
136             assert!((value_at(&keys, true, t) - t * t * (3.0 - 2.0 * t)).abs() < 1e-6);
137         }
138         assert!(value_at(&keys, true, 0.25) < 0.25);
139         assert!(value_at(&keys, true, 0.75) > 0.75);
140     }
141 
142     #[test]
143     fn smooth_mirrors_cce_ui_sample_ramp_keys() {
144         // Pinned samples of cce-ui's `layout::sample_ramp_keys` on the
145         // overview ramp and a six-key monotone profile: if either copy
146         // drifts, this and the cce-ui test disagree.
147         let (keys, _) = parse_spec("smooth;0.000:0.150,0.400:1.000,1.000:0.100").unwrap();
148         assert!((value_at(&keys, true, 0.4) - 1.0).abs() < 1e-6);
149         assert!(value_at(&keys, true, 0.39) > 0.99, "flat at the peak");
150         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();
151         let mut last = -1.0f32;
152         for i in 0..=200 {
153             let v = value_at(&keys, true, i as f32 / 200.0);
154             assert!(v >= last - 1e-6, "monotone");
155             last = v;
156         }
157         let dv = (value_at(&keys, true, 0.355) - value_at(&keys, true, 0.345)) / 0.01;
158         assert!(dv > 0.3, "a real slope at an interior key, not the old zero: {dv}");
159     }
160 }