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

commitd5ad150160fccf7148601d6f4a22118074257cbd
parentc18b6bafff
authorLucas Galante <[email protected]>
date2026-08-04 20:42
feat: ramp-driven overview transition — desktop overview_ramp/overview_ms

SetCamera { animate } now prefers a duration-based transition timed by
the configured speed ramp: desktop { overview_ramp="smooth;t:v,..."
overview_ms=380 } integrates the sculpted speed profile (policy
SpeedRamp) and the tick interpolates pan linearly and zoom in log space
by progress(elapsed/duration) — position is a pure function of time, so
a busy frame never changes where the camera lands. No/invalid ramp
falls back to the exponential-approach easing; instant camera writes
and StopPanAnimation cancel a ramp flight; a re-toggle mid-flight
restarts the ramp from the camera as it stands.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/config.rs         | 38 +++++++++++++++++++++
 src/server/window_manager.rs | 79 ++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 111 insertions(+), 6 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index dd9423d..7a40ddf 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -69,6 +69,10 @@ pub struct Layout {
     pub shadow_offset_y: i32,
     /// Magnetic grid snap for interactive move/resize.
     pub desktop_snap: bool,
+    /// Speed ramp + duration (ms) for the overview enter/exit transition.
+    /// `None` (no/invalid `desktop { overview_ramp= }`) falls back to the
+    /// exponential-approach camera animation.
+    pub overview_anim: Option<(crate::policy::ramp::SpeedRamp, f64)>,
     /// Snap radius in virtual units.
     pub desktop_snap_threshold: f64,
     /// Edge auto-pan: dragging/resizing against a screen edge scrolls the
@@ -167,6 +171,7 @@ impl Default for Layout {
             shadow_offset_x: 7,
             shadow_offset_y: 7,
             desktop_snap: true,
+            overview_anim: None,
             desktop_snap_threshold: 24.0,
             desktop_edge_pan: true,
             desktop_edge_pan_band: 32.0,
@@ -352,6 +357,10 @@ pub struct SurfaceConfig {
     pub desktop_grid_fade_mode: String,
     #[serde(default = "default_desktop_snap")]
     pub desktop_snap: bool,
+    #[serde(default)]
+    pub desktop_overview_ramp: String,
+    #[serde(default = "default_desktop_overview_ms")]
+    pub desktop_overview_ms: i64,
     #[serde(default = "default_desktop_snap_threshold")]
     pub desktop_snap_threshold: i64,
     #[serde(default = "default_desktop_edge_pan")]
@@ -414,6 +423,8 @@ impl Default for SurfaceConfig {
             desktop_cell_fade_inset: default_desktop_cell_fade_inset(),
             desktop_grid_fade_mode: default_desktop_grid_fade_mode(),
             desktop_snap: default_desktop_snap(),
+            desktop_overview_ramp: String::new(),
+            desktop_overview_ms: default_desktop_overview_ms(),
             desktop_snap_threshold: default_desktop_snap_threshold(),
             desktop_edge_pan: default_desktop_edge_pan(),
             desktop_edge_pan_band: default_desktop_edge_pan_band(),
@@ -471,6 +482,10 @@ fn default_desktop_grid_fade_mode() -> String {
     "linear".to_string()
 }
 
+fn default_desktop_overview_ms() -> i64 {
+    350
+}
+
 fn default_desktop_snap() -> bool {
     true
 }
@@ -1592,6 +1607,16 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.desktop_snap = val;
                                         }
                                     }
+                                    "overview_ramp" => {
+                                        if let Some(val) = entry.value().as_string() {
+                                            surface.desktop_overview_ramp = val.to_string();
+                                        }
+                                    }
+                                    "overview_ms" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.desktop_overview_ms = val;
+                                        }
+                                    }
                                     "snap_threshold" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.desktop_snap_threshold = val;
@@ -1758,6 +1783,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             surface.desktop_cell_fade_inset = get_child_arg_i64(node, "desktop_cell_fade_inset", default_desktop_cell_fade_inset());
             surface.desktop_grid_fade_mode = get_child_arg_string(node, "grid_fade_mode", &default_desktop_grid_fade_mode());
             surface.desktop_snap = get_child_arg_bool(node, "desktop_snap", default_desktop_snap());
+            surface.desktop_overview_ramp = get_child_arg_string(node, "desktop_overview_ramp", "");
+            surface.desktop_overview_ms = get_child_arg_i64(node, "desktop_overview_ms", default_desktop_overview_ms());
             surface.desktop_snap_threshold = get_child_arg_i64(node, "desktop_snap_threshold", default_desktop_snap_threshold());
             surface.desktop_edge_pan = get_child_arg_bool(node, "desktop_edge_pan", default_desktop_edge_pan());
             surface.desktop_edge_pan_band = get_child_arg_i64(node, "desktop_edge_pan_band", default_desktop_edge_pan_band());
@@ -1909,6 +1936,17 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.desktop_cell_color = parse_hex_color_rgba(&config.surface.desktop_cell_color);
     state.layout.desktop_grid_scale = config.surface.desktop_grid_scale as f64;
     state.layout.desktop_snap = config.surface.desktop_snap;
+    state.layout.overview_anim = if config.surface.desktop_overview_ramp.is_empty() {
+        None
+    } else {
+        match crate::policy::ramp::SpeedRamp::from_spec(&config.surface.desktop_overview_ramp) {
+            Some(ramp) => Some((ramp, (config.surface.desktop_overview_ms.max(16)) as f64)),
+            None => {
+                log::warn!("overview_ramp {:?} is invalid or all-zero; falling back to the exponential camera animation", config.surface.desktop_overview_ramp);
+                None
+            }
+        }
+    };
     state.layout.desktop_snap_threshold = config.surface.desktop_snap_threshold.max(0) as f64;
     state.layout.desktop_edge_pan = config.surface.desktop_edge_pan;
     state.layout.desktop_edge_pan_band = config.surface.desktop_edge_pan_band.max(1) as f64;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8651966..b3cb1b2 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -122,8 +122,13 @@ pub struct WindowManager {
     pub target_desk_pan_x: Option<f64>,
     pub target_desk_pan_y: Option<f64>,
     /// Eased alongside the pan targets by the animation tick — the overview
-    /// enter/exit transition (`SetCamera { animate: true }`).
+    /// enter/exit transition (`SetCamera { animate: true }`) when no speed
+    /// ramp is configured.
     pub target_desk_zoom: Option<f64>,
+    /// Duration-based camera transition driven by the configured
+    /// `desktop { overview_ramp= overview_ms= }` speed profile. When active
+    /// it owns the camera; the exponential targets above stay clear.
+    pub camera_ramp_anim: Option<CameraRampAnim>,
     pub animation_timer: *mut ffi::wl_event_source,
     /// Edge auto-pan velocity during an interactive move/resize, in SCREEN
     /// px/s (the tick divides by zoom). Written by `Seat::update_edge_pan`
@@ -209,6 +214,7 @@ impl WindowManager {
         self.target_desk_pan_x = None;
         self.target_desk_pan_y = None;
         self.target_desk_zoom = None;
+        self.camera_ramp_anim = None;
         self.animation_timer = std::ptr::null_mut();
         self.edge_pan_vx = 0.0;
         self.edge_pan_vy = 0.0;
@@ -843,6 +849,7 @@ impl WindowManager {
         self.target_desk_pan_x = None;
         self.target_desk_pan_y = None;
         self.target_desk_zoom = None;
+        self.camera_ramp_anim = None;
     }
 
     /// The current camera as the policy crate's plain-data snapshot.
@@ -4031,9 +4038,30 @@ impl crate::policy::api::Compositor for WindowManager {
                         self.mode = if overview { WindowManagerMode::Overview } else { WindowManagerMode::Normal };
                     }
                     if animate {
-                        self.target_desk_pan_x = Some(camera.pan_x);
-                        self.target_desk_pan_y = Some(camera.pan_y);
-                        self.target_desk_zoom = Some(camera.zoom);
+                        if let Some((_, duration_ms)) = self.layout.overview_anim {
+                            // Ramp-driven: a fixed-duration transition from
+                            // the camera as it stands (a re-toggle mid-flight
+                            // restarts the ramp from here). The exponential
+                            // targets stay clear — the ramp owns the camera.
+                            self.camera_ramp_anim = Some(CameraRampAnim {
+                                start_pan_x: self.desk_pan_x,
+                                start_pan_y: self.desk_pan_y,
+                                start_ln_zoom: self.desk_zoom.max(1e-6).ln(),
+                                target_pan_x: camera.pan_x,
+                                target_pan_y: camera.pan_y,
+                                target_ln_zoom: camera.zoom.max(1e-6).ln(),
+                                started: std::time::Instant::now(),
+                                duration_ms,
+                            });
+                            self.target_desk_pan_x = None;
+                            self.target_desk_pan_y = None;
+                            self.target_desk_zoom = None;
+                        } else {
+                            self.camera_ramp_anim = None;
+                            self.target_desk_pan_x = Some(camera.pan_x);
+                            self.target_desk_pan_y = Some(camera.pan_y);
+                            self.target_desk_zoom = Some(camera.zoom);
+                        }
                         self.start_panning_animation();
                     } else {
                         self.desk_pan_x = camera.pan_x;
@@ -4044,6 +4072,7 @@ impl crate::policy::api::Compositor for WindowManager {
                         self.target_desk_pan_x = None;
                         self.target_desk_pan_y = None;
                         self.target_desk_zoom = None;
+                        self.camera_ramp_anim = None;
                     }
                 }
                 Command::PanTo { x, y } => {
@@ -4190,15 +4219,53 @@ pub(crate) unsafe extern "C" fn handle_edge_pan_tick(data: *mut std::ffi::c_void
     0
 }
 
+/// A duration-based camera transition: linear pan / log-space zoom
+/// interpolation, timed through the configured overview speed ramp.
+pub struct CameraRampAnim {
+    pub start_pan_x: f64,
+    pub start_pan_y: f64,
+    pub start_ln_zoom: f64,
+    pub target_pan_x: f64,
+    pub target_pan_y: f64,
+    pub target_ln_zoom: f64,
+    pub started: std::time::Instant,
+    pub duration_ms: f64,
+}
+
 pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let wm = data as *mut WindowManager;
     if wm.is_null() {
         return 0;
     }
-    
+
     let mut done = true;
     let factor = 0.15;
-    
+
+    // Ramp-driven transition: position is a pure function of elapsed time,
+    // so a stalled tick (busy frame) never changes where the camera lands.
+    if let Some(anim) = &(*wm).camera_ramp_anim {
+        let t = anim.started.elapsed().as_secs_f64() * 1000.0 / anim.duration_ms;
+        if t >= 1.0 {
+            (*wm).desk_pan_x = anim.target_pan_x;
+            (*wm).desk_pan_y = anim.target_pan_y;
+            (*wm).desk_zoom = anim.target_ln_zoom.exp();
+            (*wm).camera_ramp_anim = None;
+        } else if let Some((ramp, _)) = &(*wm).layout.overview_anim {
+            let p = ramp.progress(t);
+            (*wm).desk_pan_x = anim.start_pan_x + (anim.target_pan_x - anim.start_pan_x) * p;
+            (*wm).desk_pan_y = anim.start_pan_y + (anim.target_pan_y - anim.start_pan_y) * p;
+            (*wm).desk_zoom =
+                (anim.start_ln_zoom + (anim.target_ln_zoom - anim.start_ln_zoom) * p).exp();
+            done = false;
+        } else {
+            // Ramp was unconfigured mid-flight (reload): land instantly.
+            (*wm).desk_pan_x = anim.target_pan_x;
+            (*wm).desk_pan_y = anim.target_pan_y;
+            (*wm).desk_zoom = anim.target_ln_zoom.exp();
+            (*wm).camera_ramp_anim = None;
+        }
+    }
+
     if let Some(target_x) = (*wm).target_desk_pan_x {
         let dx = target_x - (*wm).desk_pan_x;
         if dx.abs() > 0.5 {