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