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 }