git.lucas.co / cce-core
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 }