git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit750bea84a911b1473ae0c3b8abf2abd5bfaa5298
parent8c2f2d334c
authorLucas Galante <[email protected]>
date2026-09-24 22:05
feat(motion): a DE-wide animations switch every easing asks

cce_ui::motion::enabled() reads /run/cce/animations (on/off, missing
means on), re-checked at most every 500ms so a running client follows a
change without a restart; CCE_ANIMATIONS overrides it per process. The
file is written as root by cce-system-interface's cce-power-apply when
the running power mode sets its Animations lever.

Off means snap, not freeze: the dropdown open/close, toggle slide,
scrollbar raise/sink fade, wheel glide and kinetic coast
(scroll_settings() reports both off, and an in-flight glide lands),
slider and ramp wheel inertia, and the hover highlight all reach their
target in one step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

 src/lib.rs                   |  1 +
 src/motion.rs                | 77 ++++++++++++++++++++++++++++++++++++++++++++
 src/widget/core.rs           | 13 +++++---
 src/widget/input/checkbox.rs |  4 +++
 src/widget/input/dropdown.rs |  8 +++--
 src/widget/input/ramp.rs     |  3 +-
 src/widget/input/slider.rs   |  3 +-
 src/widget/scroll_motion.rs  | 23 +++++++++++++
 src/widget/scroll_region.rs  |  2 +-
 9 files changed, 124 insertions(+), 10 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index a7adb16..31659b6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -18,6 +18,7 @@ pub mod file_dialog;
 pub mod icon;
 pub mod ipc;
 pub mod mcp;
+pub mod motion;
 pub mod vk;
 
 pub mod colors {
diff --git a/src/motion.rs b/src/motion.rs
new file mode 100644
index 0000000..443bf19
--- /dev/null
+++ b/src/motion.rs
@@ -0,0 +1,77 @@
+//! The DE-wide animations switch.
+//!
+//! One flag, followed by every cce-ui widget that eases and by the
+//! compositor: when it is off, anything that would glide, fade, slide or
+//! coast lands on its target in the same frame instead. Disabled means
+//! "snap", never "freeze" — a dropdown still opens, a scroll still moves.
+//!
+//! The switch is a file, [`STATE_PATH`], holding `on` or `off`. The System
+//! Interface's Power page sets it per power mode and `cce-power-apply`
+//! writes it as root whenever the mode changes (plug, unplug, boot), which
+//! is why it lives under /run rather than in `~/.config/cce`: the writer has
+//! no session and no `$HOME`. No file means on.
+//!
+//! [`enabled`] is cheap enough for per-frame use: it re-reads the file at
+//! most every [`RECHECK`], so a running client follows a mode change within
+//! half a second and nothing needs restarting or reloading. `CCE_ANIMATIONS`
+//! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
+
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+/// Where the switch lives. Shared with `cce-power-apply`, the writer.
+pub const STATE_PATH: &str = "/run/cce/animations";
+
+/// How stale [`enabled`] may be. A mode change is a plug or an unplug, so
+/// half a second is instant to a person, and the stat stays off the frame.
+pub const RECHECK: Duration = Duration::from_millis(500);
+
+/// `on` / `off` (surrounding whitespace ignored); anything else says nothing.
+pub fn parse(text: &str) -> Option<bool> {
+    match text.trim() {
+        "on" | "1" | "true" => Some(true),
+        "off" | "0" | "false" => Some(false),
+        _ => None,
+    }
+}
+
+/// What the state file says right now, uncached. `None` when there is no
+/// file or it holds nothing recognizable — both of which mean "animate".
+pub fn read_state() -> Option<bool> {
+    parse(&std::fs::read_to_string(STATE_PATH).ok()?)
+}
+
+static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
+
+/// Whether to animate. Every easing in the toolkit asks this before it
+/// steps, and snaps to its target when the answer is no.
+pub fn enabled() -> bool {
+    static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
+    if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
+        return forced;
+    }
+    let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
+    let now = Instant::now();
+    match *cache {
+        Some((at, value)) if now.duration_since(at) < RECHECK => value,
+        _ => {
+            let value = read_state().unwrap_or(true);
+            *cache = Some((now, value));
+            value
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_reads_the_helpers_spelling_and_nothing_else() {
+        assert_eq!(parse("off\n"), Some(false));
+        assert_eq!(parse("on"), Some(true));
+        assert_eq!(parse(" 0 "), Some(false));
+        assert_eq!(parse(""), None);
+        assert_eq!(parse("disabled"), None);
+    }
+}
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 0ab8067..d83689e 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -191,6 +191,9 @@ pub mod hover_animation {
         HOVER_STATE.with(|state| {
             let mut s = state.borrow_mut();
             let decay = 15.0;
+            // The per-tick approach fraction; 1 lands on the target at once,
+            // which is the whole of animations-off for the highlight.
+            let k = if crate::motion::enabled() { 1.0 - (-decay * dt).exp() } else { 1.0 };
             let mut changed = false;
 
             if s.current_alpha <= 0.001 && s.target_alpha > 0.0 {
@@ -203,7 +206,7 @@ pub mod hover_animation {
             }
 
             if (s.current_alpha - s.target_alpha).abs() > 0.001 {
-                s.current_alpha += (s.target_alpha - s.current_alpha) * (1.0 - (-decay * dt).exp());
+                s.current_alpha += (s.target_alpha - s.current_alpha) * k;
                 changed = true;
             } else if s.current_alpha != s.target_alpha {
                 s.current_alpha = s.target_alpha;
@@ -212,7 +215,7 @@ pub mod hover_animation {
 
             if let (Some(tx), Some(ty), Some(tw), Some(th)) = (s.target_x, s.target_y, s.target_w, s.target_h) {
                 if (s.current_x - tx).abs() > 0.1 {
-                    s.current_x += (tx - s.current_x) * (1.0 - (-decay * dt).exp());
+                    s.current_x += (tx - s.current_x) * k;
                     changed = true;
                 } else if s.current_x != tx {
                     s.current_x = tx;
@@ -220,7 +223,7 @@ pub mod hover_animation {
                 }
 
                 if (s.current_y - ty).abs() > 0.1 {
-                    s.current_y += (ty - s.current_y) * (1.0 - (-decay * dt).exp());
+                    s.current_y += (ty - s.current_y) * k;
                     changed = true;
                 } else if s.current_y != ty {
                     s.current_y = ty;
@@ -228,7 +231,7 @@ pub mod hover_animation {
                 }
 
                 if (s.current_w - tw).abs() > 0.1 {
-                    s.current_w += (tw - s.current_w) * (1.0 - (-decay * dt).exp());
+                    s.current_w += (tw - s.current_w) * k;
                     changed = true;
                 } else if s.current_w != tw {
                     s.current_w = tw;
@@ -236,7 +239,7 @@ pub mod hover_animation {
                 }
 
                 if (s.current_h - th).abs() > 0.1 {
-                    s.current_h += (th - s.current_h) * (1.0 - (-decay * dt).exp());
+                    s.current_h += (th - s.current_h) * k;
                     changed = true;
                 } else if s.current_h != th {
                     s.current_h = th;
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 5f0c946..8fdc1f3 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -567,6 +567,10 @@ impl Input for Toggle {
         if d.abs() < 0.001 {
             return false;
         }
+        if !crate::motion::enabled() {
+            self.slide_t = target;
+            return true;
+        }
         self.slide_t += d * (1.0 - (-dt * 22.0).exp());
         if (target - self.slide_t).abs() < 0.005 {
             self.slide_t = target;
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index df7a467..249028b 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -253,6 +253,10 @@ impl Dropdown {
         let Some(start) = self.anim_start else {
             return if self.open && !self.closing { 1.0 } else { 0.0 };
         };
+        // Animations off: a transition in flight has already landed.
+        if !crate::motion::enabled() {
+            return if self.closing { 0.0 } else { 1.0 };
+        }
         let el = start.elapsed().as_secs_f32() / Self::ANIM_S;
         if self.closing {
             (self.anim_from - el).clamp(0.0, 1.0)
@@ -266,7 +270,7 @@ impl Dropdown {
         self.anim_start = Some(std::time::Instant::now());
         self.open = true;
         self.closing = false;
-        self.anim_snap = self.anim_from;
+        self.anim_snap = self.anim_progress_now();
     }
 
     fn begin_close(&mut self) {
@@ -276,7 +280,7 @@ impl Dropdown {
         self.anim_from = self.anim_progress_now();
         self.anim_start = Some(std::time::Instant::now());
         self.closing = true;
-        self.anim_snap = self.anim_from;
+        self.anim_snap = self.anim_progress_now();
     }
 
     /// Fold finished animations back into settled state and refresh the
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 5bb0cfa..40a0701 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -1408,7 +1408,8 @@ impl Input for Ramp {
         if let (Some(idx), Some(last)) = (self.scroll_key_idx, self.last_key_scroll) {
             if last.elapsed().as_secs_f32() > 0.06 && idx < self.keys.len() {
                 let (vx, vy) = self.scroll_vel;
-                if vx.abs() > 0.02 || vy.abs() > 0.02 {
+                // Animations off: the key stops where the scroll left it.
+                if (vx.abs() > 0.02 || vy.abs() > 0.02) && crate::motion::enabled() {
                     self.keys[idx].pos = (self.keys[idx].pos + vx * dt).clamp(0.0, 1.0);
                     self.keys[idx].value = (self.keys[idx].value + vy * dt).clamp(0.0, 1.0);
                     let settled = self.resettle_key(idx);
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 2347fe7..79a46a6 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -498,7 +498,8 @@ impl Input for Slider {
         if last.elapsed().as_secs_f32() <= 0.06 {
             return false;
         }
-        if self.scroll_vel.abs() > 0.02 && !self.dragging && !self.editing {
+        // Animations off: the value stops where the wheel left it.
+        if self.scroll_vel.abs() > 0.02 && !self.dragging && !self.editing && crate::motion::enabled() {
             let new_val = (self.value + self.scroll_vel * dt).clamp(0.0, 1.0);
             let moved = self.set_value_marking(new_val);
             if crate::scroll_debug() {
diff --git a/src/widget/scroll_motion.rs b/src/widget/scroll_motion.rs
index aaf50a9..f525744 100644
--- a/src/widget/scroll_motion.rs
+++ b/src/widget/scroll_motion.rs
@@ -113,7 +113,19 @@ impl Default for ScrollSettings {
 static SETTINGS: std::sync::OnceLock<ScrollSettings> = std::sync::OnceLock::new();
 
 /// This app's effective smooth-scroll settings (`<app>` → `cce-ui` → defaults).
+/// With animations off ([`crate::motion`]) a wheel notch jumps and a flick
+/// stops at the lift — the legacy behavior — whatever input.kdl says; that
+/// is checked per call, so it follows the switch while the app runs.
 pub fn scroll_settings() -> ScrollSettings {
+    let configured = configured_scroll_settings();
+    if crate::motion::enabled() {
+        configured
+    } else {
+        ScrollSettings { smooth: false, kinetic: false, ..configured }
+    }
+}
+
+fn configured_scroll_settings() -> ScrollSettings {
     *SETTINGS.get_or_init(|| {
         let input = crate::input::cached();
         let app = crate::config::get_app_name().unwrap_or_default();
@@ -333,6 +345,17 @@ impl ScrollAxis {
         let old = self.pos;
         match self.mode {
             Mode::Idle | Mode::Tracking => return false,
+            // Settings that forbid the motion already in flight (animations
+            // switched off mid-glide) land it rather than finish it.
+            Mode::Easing if !s.smooth => {
+                self.pos = self.target;
+                self.mode = Mode::Idle;
+            }
+            Mode::Coasting if !s.kinetic => {
+                self.vel = 0.0;
+                self.target = self.pos;
+                self.mode = Mode::Idle;
+            }
             Mode::Easing => {
                 let remaining = self.target - self.pos;
                 if remaining.abs() <= SNAP_PX {
diff --git a/src/widget/scroll_region.rs b/src/widget/scroll_region.rs
index 9b5f32d..03ff4b9 100644
--- a/src/widget/scroll_region.rs
+++ b/src/widget/scroll_region.rs
@@ -121,7 +121,7 @@ impl ScrollbarActivity {
         // reversed halfway takes proportionally less time rather than
         // restarting — a flick-scroll-flick does not stutter.
         let target = if self.raised { 1.0 } else { 0.0 };
-        let step = if SCROLL_FADE_SECS > 0.0 { dt / SCROLL_FADE_SECS } else { 1.0 };
+        let step = if SCROLL_FADE_SECS > 0.0 && crate::motion::enabled() { dt / SCROLL_FADE_SECS } else { 1.0 };
         let moved = if (self.fade - target).abs() <= step {
             let done = self.fade != target;
             self.fade = target;