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 }