GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git
src/motion.rs (5.2K)
1 //! The DE-wide animations switch.
2 //!
3 //! One flag, followed by every cce-ui widget that eases and by the
4 //! compositor: when it is off, anything that would glide, fade or slide
5 //! lands on its target in the same frame instead. Disabled means
6 //! "snap", never "freeze" — a dropdown still opens, a scroll still moves.
7 //! A trackpad flick's coast is NOT under it (`widget::scroll_motion`): that
8 //! is the hand's gesture carried on, not an animation the toolkit adds.
9 //!
10 //! The switch is a file, [`STATE_PATH`], holding `on` or `off`. The System
11 //! Interface's Power page sets it per power mode and `cce-power-apply`
12 //! writes it as root whenever the mode changes (plug, unplug, boot), which
13 //! is why it lives under /run rather than in `~/.config/cce`: the writer has
14 //! no session and no `$HOME`. No file means on.
15 //!
16 //! [`enabled`] is cheap enough for per-frame use: it re-reads the file at
17 //! most every [`RECHECK`], so a running client follows a mode change within
18 //! half a second and nothing needs restarting or reloading. `CCE_ANIMATIONS`
19 //! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
20
21 use std::time::Duration;
22
23 /// Where the switch lives. Shared with `cce-power-apply`, the writer.
24 pub const STATE_PATH: &str = "/run/cce/animations";
25
26 /// How stale [`enabled`] may be. A mode change is a plug or an unplug, so
27 /// half a second is instant to a person, and the stat stays off the frame.
28 pub const RECHECK: Duration = Duration::from_millis(500);
29
30 /// `on` / `off` (surrounding whitespace ignored); anything else says nothing.
31 pub fn parse(text: &str) -> Option<bool> {
32 match text.trim() {
33 "on" | "1" | "true" => Some(true),
34 "off" | "0" | "false" => Some(false),
35 _ => None,
36 }
37 }
38
39 /// What the state file says right now, uncached. `None` when there is no
40 /// file or it holds nothing recognizable — both of which mean "animate".
41 pub fn read_state() -> Option<bool> {
42 parse(&std::fs::read_to_string(STATE_PATH).ok()?)
43 }
44
45 // Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
46 // unless a test forces it with [`force_for_test`]. Until 2026-09-28
47 // `enabled` read `/run/cce/animations` in the test binary too, so three
48 // glide and fade tests passed or failed with the laptop's power mode —
49 // off on battery, on when plugged in — and read as a broken glide rather
50 // than a borrowed switch. Thread-local rather than the shared cache,
51 // because libtest runs tests in parallel and a process-wide override set
52 // by one test would race every other test's read; a test that forces it
53 // does so for its own thread only, and the value resets with the thread.
54 #[cfg(any(test, feature = "test-isolation"))]
55 thread_local! {
56 static FORCED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
57 }
58
59 /// Set what [`enabled`] answers on this thread, for a test that exercises
60 /// the snap-instead-of-ease path. Tests never set `CCE_ANIMATIONS`, since
61 /// an environment variable is process-wide.
62 #[cfg(any(test, feature = "test-isolation"))]
63 pub fn force_for_test(value: bool) {
64 FORCED.with(|f| f.set(value));
65 }
66
67 /// Whether to animate. Every easing in the toolkit asks this before it
68 /// steps, and snaps to its target when the answer is no.
69 pub fn enabled() -> bool {
70 #[cfg(any(test, feature = "test-isolation"))]
71 {
72 FORCED.with(|f| f.get())
73 }
74 #[cfg(not(any(test, feature = "test-isolation")))]
75 enabled_on_this_machine()
76 }
77
78 /// [`enabled`] as the shipped binary answers it: the `CCE_ANIMATIONS`
79 /// override for this process, else the state file, re-read at most every
80 /// [`RECHECK`].
81 #[cfg(not(any(test, feature = "test-isolation")))]
82 fn enabled_on_this_machine() -> bool {
83 static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
84 if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
85 return forced;
86 }
87 use std::sync::Mutex;
88 use web_time::Instant;
89 static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
90 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
91 let now = Instant::now();
92 match *cache {
93 Some((at, value)) if now.duration_since(at) < RECHECK => value,
94 _ => {
95 let value = read_state().unwrap_or(true);
96 *cache = Some((now, value));
97 value
98 }
99 }
100 }
101
102 #[cfg(test)]
103 mod tests {
104 use super::*;
105
106 #[test]
107 fn parse_reads_the_helpers_spelling_and_nothing_else() {
108 assert_eq!(parse("off\n"), Some(false));
109 assert_eq!(parse("on"), Some(true));
110 assert_eq!(parse(" 0 "), Some(false));
111 assert_eq!(parse(""), None);
112 assert_eq!(parse("disabled"), None);
113 }
114
115 /// The suite's switch is on whatever the machine's file says, a test
116 /// can force it off for its own thread only, and another thread still
117 /// sees it on.
118 #[test]
119 fn the_suite_animates_whatever_the_machine_says() {
120 assert!(enabled(), "on by default, not read off {STATE_PATH}");
121 force_for_test(false);
122 assert!(!enabled(), "a test can force the snap path");
123 let elsewhere = std::thread::spawn(enabled).join().unwrap();
124 assert!(elsewhere, "the force is this thread's alone");
125 force_for_test(true);
126 assert!(enabled());
127 }
128 }