Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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();