git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commita867bb47fc887d2acaff437df49064d0adf83e16
parent701b32c973
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-24 22:05
Follow the DE-wide animations switch

With cce_ui::motion::enabled() off, the open/close fades (window,
overlay layer, and the fade-out reply, which answers 0), the camera
eases (overview ramp, focus pans, kinetic pan), the border-reveal and
adjust-dim fades, and the fullscreen-toggle resize all land at once.
WORKSPACE.md documents the switch and what follows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 WORKSPACE.md                 | 23 +++++++++++++++++++++++
 src/server/layer_shell.rs    |  2 +-
 src/server/window.rs         | 24 +++++++++++++++++++++---
 src/server/window_manager.rs | 16 +++++++++++++---
 4 files changed, 58 insertions(+), 7 deletions(-)

diff --git a/WORKSPACE.md b/WORKSPACE.md
index e1f71f12..59f35386 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -361,6 +361,29 @@ compositor-side and appears the moment a new `cce-fx` is running, but the
 fade-OUT rides `cce-ui`, so a client nobody rebuilt fades in and then vanishes.
 That asymmetry is the symptom of a missed sweep, not of a broken fade.
 
+## Animations switch (DE-wide, per power mode)
+
+`cce_ui::motion::enabled()` is the one question every easing in the DE asks
+before it steps; when it answers no, the motion lands on its target in the
+same frame. That means snap, not freeze: a dropdown still opens and a scroll
+still moves. Its source is **`/run/cce/animations`** (`on`/`off`, missing
+means on), written as root by `cce-power-apply` when the Power page's
+**Animations** lever is part of the mode that is running. It lives under
+/run, not `~/.config`, because the writer runs from udev with no session and
+no `$HOME`. `enabled()` re-reads it at most every 500 ms, so a plug or unplug
+reaches running clients and the compositor without a reload. Set
+`CCE_ANIMATIONS=0` (or `1`) to force it for one process, for testing.
+
+What follows the switch: in cce-ui, the dropdown open/close, the toggle
+slide, the scrollbar raise/sink fade, the wheel glide and kinetic coast
+(`scroll_settings()` reports both off), slider and ramp wheel inertia, and
+the hover highlight. In the compositor, the open/close fades (window,
+overlay layer, and the `fade-out` reply, which answers 0), the camera eases
+(`advance_camera_animation`: overview ramp, focus pans, kinetic pan), the
+border-reveal/adjust-dim fades, and the fullscreen-toggle resize. **A new
+animation should ask `enabled()` too.** Like the close fade, the cce-ui half
+reaches a client only once that client has been rebuilt against the toolkit.
+
 ## Repo hygiene
 
 The repo root and `cce-compositor/scratch/` are littered with **ad-hoc debugging artifacts** — many
diff --git a/src/server/layer_shell.rs b/src/server/layer_shell.rs
index da3dd744..c6e7954c 100644
--- a/src/server/layer_shell.rs
+++ b/src/server/layer_shell.rs
@@ -575,7 +575,7 @@ unsafe extern "C" fn handle_layer_surface_map(listener: *mut ffi::wl_listener, _
     // furniture — wallpaper, status bar — and map once at login, where a
     // fade reads as the desktop failing to draw.
     if (*wlr_layer_surface).current.layer == ffi::zwlr_layer_shell_v1_layer_ZWLR_LAYER_SHELL_V1_LAYER_OVERLAY {
-        let ms = (*server).wm.layout.fade_in_ms;
+        let ms = if cce_ui::motion::enabled() { (*server).wm.layout.fade_in_ms } else { 0 };
         if ms > 0 {
             (*layer_surface).opacity = 0.0;
         }
diff --git a/src/server/window.rs b/src/server/window.rs
index 004f577d..24e879a1 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -177,6 +177,12 @@ pub const BORDER_FADE_STEP: f32 = 0.15;
 /// Below this the fade is treated as finished and snapped to its target.
 pub const BORDER_FADE_EPSILON: f32 = 0.004;
 
+/// The hover/dim step in force: [`BORDER_FADE_STEP`], or the whole distance
+/// when animations are off (`cce_ui::motion`), which lands in one tick.
+fn border_fade_step() -> f32 {
+    if cce_ui::motion::enabled() { BORDER_FADE_STEP } else { 1.0 }
+}
+
 /// Per-tick step of the fullscreen-toggle animation, as a fraction of the
 /// remaining distance to the target rect (the pan/border-fade shape).
 pub const FS_ANIM_STEP: f64 = 0.22;
@@ -2070,7 +2076,9 @@ impl Window {
         // that failed to map never starts one. `start_map_fade` snaps rather
         // than ramps when fading is off or this surface opts out (status
         // segments, wallpaper), so there is no second branch here.
-        let fade_ms = (*self.server).wm.layout.fade_in_ms;
+        // Animations off (`cce_ui::motion`) is a zero-length fade, the
+        // same as `surface { fade in_ms=0 }`.
+        let fade_ms = if cce_ui::motion::enabled() { (*self.server).wm.layout.fade_in_ms } else { 0 };
         if self.wants_map_fade() && fade_ms > 0 {
             self.map_fade = 0.0;
         }
@@ -4030,7 +4038,7 @@ impl Window {
             self.adjust_dim = target;
             moving = false;
         } else {
-            self.adjust_dim += delta * BORDER_FADE_STEP;
+            self.adjust_dim += delta * border_fade_step();
             moving = true;
         }
         ffi::river_scene_node_set_opacity(self.tree as *mut ffi::wlr_scene_node, self.effective_opacity());
@@ -4063,7 +4071,7 @@ impl Window {
                 }
                 continue;
             }
-            self.border_reveal[i] += delta * BORDER_FADE_STEP;
+            self.border_reveal[i] += delta * border_fade_step();
             moving = true;
             changed = true;
         }
@@ -4104,6 +4112,11 @@ impl Window {
     /// already been rewritten to the destination state's scale by the arrange
     /// pass in this same cycle.
     unsafe fn start_fs_anim(&mut self) {
+        // Animations off: the window is simply drawn at its new rect.
+        if !cce_ui::motion::enabled() {
+            self.fs_anim = None;
+            return;
+        }
         if !matches!(self.impl_type, WindowImpl::Toplevel(_))
             || !matches!(self.state, WindowState::Mapped)
             || self.box_geom.width <= 0
@@ -4136,6 +4149,11 @@ impl Window {
         let Some(mut anim) = self.fs_anim else {
             return false;
         };
+        // Switched off mid-flight: land now, with the settling frame.
+        if !cce_ui::motion::enabled() {
+            self.fs_anim = None;
+            return true;
+        }
 
         let (tx, ty, tw, th) = if self.is_fullscreen() {
             let output = self.fullscreen_output();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 0f3535a9..125e558a 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2068,9 +2068,13 @@ impl WindowManager {
     /// nothing is left to animate.
     unsafe fn advance_camera_animation(&mut self, dt: f64, frame_target_ns: u64) -> bool {
         let mut done = true;
+        // Animations off (`cce_ui::motion`): every ease below covers its
+        // whole distance in this step, the ramp lands, and a flick does not
+        // coast — the camera still goes where it was sent, just at once.
+        let animate = cce_ui::motion::enabled();
         // Frame-rate independent exponential approach: the same fraction of
         // the remaining distance per unit time whatever the frame pacing.
-        let factor = 1.0 - (-self.scroll_ease_rate() * dt).exp();
+        let factor = if animate { 1.0 - (-self.scroll_ease_rate() * dt).exp() } else { 1.0 };
 
         // Ramp-driven transition: position is a pure function of elapsed
         // time, so a stalled frame never changes where the camera lands.
@@ -2078,7 +2082,7 @@ impl WindowManager {
             (a.start, a.target, frame_target_ns.saturating_sub(a.started_ns) as f64 / 1e6 / a.duration_ms)
         });
         if let Some((start, target, t)) = ramp {
-            if t >= 1.0 {
+            if t >= 1.0 || !animate {
                 self.desk_pan_x = target.pan_x;
                 self.desk_pan_y = target.pan_y;
                 self.desk_zoom = target.zoom;
@@ -2151,6 +2155,10 @@ impl WindowManager {
 
         // Kinetic pan: a trackpad flick's velocity carries the desktop on,
         // decaying under friction; it stalls below one screen pixel per frame.
+        if !animate {
+            self.pan_coast_vx = 0.0;
+            self.pan_coast_vy = 0.0;
+        }
         if self.pan_coast_vx != 0.0 || self.pan_coast_vy != 0.0 {
             self.desk_pan_x += self.pan_coast_vx * dt;
             self.desk_pan_y += self.pan_coast_vy * dt;
@@ -4789,7 +4797,9 @@ impl WindowManager {
             // reply is the duration in ms, always — a client that gets "0"
             // simply exits at once, which is what a disabled fade means.
             "fade-out" => {
-                let ms = self.layout.fade_out_ms;
+                // Animations off answers 0 like a disabled fade: the client
+                // exits at once and nothing ramps.
+                let ms = if cce_ui::motion::enabled() { self.layout.fade_out_ms } else { 0 };
                 let pid = self.pending_ipc_peer_pid;
                 if pid <= 0 {
                     return "0\n".to_string();