GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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;