From c1801e1f0780f54916c03eca0084ee5fa32c0f73 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 4 Oct 2026 07:53:34 +0100 Subject: [PATCH] fix overview entrance animation --- compositor/src/overview.rs | 52 +++--- compositor/src/state.rs | 28 +++- compositor/src/state/animation.rs | 1 + compositor/src/state/navigation.rs | 24 ++- compositor/src/state/prediction.rs | 155 ++++++++++++++++-- shell/ferese-shell/src/status_ui/lifecycle.rs | 102 +++++++++++- 6 files changed, 319 insertions(+), 43 deletions(-) diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index a918860..32d5a81 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -16,7 +16,7 @@ use crate::Ferese; const OVERVIEW_MARGIN: f64 = 48.0; const OVERVIEW_GAP: f64 = 40.0; // Applied after the configured animation speed, in both live and predicted motion. -const OVERVIEW_MOTION_SPEED: f64 = 0.6; +pub(crate) const OVERVIEW_MOTION_SPEED: f64 = 0.6; const WORKSPACE_CARD_GAP: f64 = 8.0; const MAX_PREVIEW_SCALE: f64 = 0.82; @@ -804,11 +804,15 @@ impl Ferese { return true; }; - if self - .output_workspaces - .switch_workspace(output_id, card.workspace) - .is_ok() + // Consume idle time before creating the new slide. Subsequent + // clicks then retarget the motion currently visible on this output. + self.advance_animations(std::time::Instant::now()); + let previous = self.output_workspaces.active_workspace(output_id); + if let Ok( + ferese_core::WorkspaceSwitch::Activated(owner) | ferese_core::WorkspaceSwitch::FocusedExisting(owner), + ) = self.output_workspaces.switch_workspace(output_id, card.workspace) { + self.update_workspace_slide(output_id, owner, previous, card.workspace, None); // Existing monitor ownership wins; never steal another output's workspace. self.restore_output_focus(); self.overview.strip_offsets.clear(); @@ -881,22 +885,30 @@ impl Ferese { } continue; } - let Some(workspace) = self.workspaces.workspace(workspace) else { - continue; - }; - let windows = workspace - .layout - .window_ids() - .chain(workspace.floating.iter().copied()) - .filter_map(|id| { - let normal = self.windows.geometry(&id)?.visual.current; - Some((id, normal)) - }) - .collect::>(); + let mut visible = vec![workspace]; + let sliding = self.workspace_slide_workspaces(output_id).collect::>(); + visible.extend(sliding.iter().copied().filter(|id| *id != workspace)); + for workspace in visible { + let Some(workspace) = self.workspaces.workspace(workspace) else { + continue; + }; + let windows = workspace + .layout + .window_ids() + .chain(workspace.floating.iter().copied()) + .filter_map(|id| { + let normal = self.windows.geometry(&id)?.visual.current; + Some((id, normal)) + }) + .collect::>(); - for (id, target) in overview_layout(window_area(bounds), &windows) { - let normal = self.windows.geometry(&id).unwrap().visual.current; - targets.insert(id, (normal, target)); + for (id, target) in overview_layout(window_area(bounds), &windows) { + let normal = self.windows.geometry(&id).unwrap().visual.current; + // Incoming grids start at their overview size. Translation + // belongs to the workspace slide, not a second desktop zoom. + let source = if !sliding.is_empty() { target } else { normal }; + targets.insert(id, (source, target)); + } } } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index dafd783..001f6ab 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -206,6 +206,7 @@ struct WorkspaceSlideItem { #[derive(Clone)] struct WorkspaceSlide { + speed: f64, items: Vec, spring: SpringConfig, held_progress: Option, @@ -253,6 +254,7 @@ impl WorkspaceSlide { } Self { + speed: 1.0, items, spring: SpringConfig { stiffness: 320.0, @@ -271,6 +273,7 @@ impl WorkspaceSlide { } fn sample(&self, item: &WorkspaceSlideItem, delta: Duration) -> (SlideOffset, SlideOffset) { + let delta = delta.mul_f64(self.speed); if let Some(progress) = self.held_progress { return (item.start.between(item.target, progress), item.velocity); } @@ -292,8 +295,8 @@ impl WorkspaceSlide { y: y.current, }, SlideOffset { - x: x.velocity, - y: y.velocity, + x: x.velocity * self.speed, + y: y.velocity * self.speed, }, ) } @@ -332,6 +335,7 @@ impl WorkspaceSlide { } fn advance(&mut self, delta: Duration) -> bool { + let delta = delta.mul_f64(self.speed); if self.held_progress.is_some() { return true; } @@ -1138,6 +1142,26 @@ impl ClientData for ClientState { #[cfg(test)] mod tests { + #[test] + fn overview_slide_prediction_uses_the_same_clock_as_live_motion() { + let mut slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Left); + slide.speed = crate::overview::OVERVIEW_MOTION_SPEED; + for delta in [ + Duration::from_millis(8), + Duration::from_millis(100), + Duration::from_secs(2), + ] { + let mut advanced = slide.clone(); + advanced.advance(delta); + for (item, after) in slide.items.iter().zip(&advanced.items) { + let (position, velocity) = slide.sample(item, delta); + assert_eq!(position, after.start); + assert_eq!(velocity.x, after.velocity.x * slide.speed); + assert_eq!(velocity.y, after.velocity.y * slide.speed); + } + } + } + #[test] fn slide_item_sampling_matches_advance_without_mutating_the_slide() { let mut slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Up); diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 98e67e0..3212ef6 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -500,6 +500,7 @@ impl Ferese { self.unmap_invisible_windows(&visible_windows); self.reconcile_workspaces(&visible); self.send_shell_snapshots(); + self.retarget_overview(); active_animation = true; } if !self.overview.is_active() { diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 087b5ff..da7a7ca 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -502,7 +502,14 @@ impl Ferese { true } - pub(super) fn update_workspace_slide( + pub(crate) fn workspace_slide_workspaces(&self, output: OutputId) -> impl Iterator + '_ { + self.workspace_slides + .get(&output) + .into_iter() + .flat_map(|slide| slide.items.iter().map(|item| item.workspace)) + } + + pub(crate) fn update_workspace_slide( &mut self, requested_output: OutputId, owner: OutputId, @@ -539,7 +546,13 @@ impl Ferese { .collect::>(); let from = ids.iter().position(|id| *id == from)?; let to = ids.iter().position(|id| *id == workspace)?; - Some(if to > from { + Some(if self.overview.is_active() { + if to > from { + SwipeDirection::Left + } else { + SwipeDirection::Right + } + } else if to > from { SwipeDirection::Up } else { SwipeDirection::Down @@ -549,10 +562,15 @@ impl Ferese { && let (Some(from), Some(direction)) = (previous, slide_direction) && from != workspace && self.animations_enabled - && !self.overview.is_presenting() + && (!self.overview.is_presenting() || self.overview.is_active()) { self.last_animation_tick = Instant::now(); let mut slide = WorkspaceSlide::new(previous_slide, from, workspace, direction); + slide.speed = if self.overview.is_active() { + crate::overview::OVERVIEW_MOTION_SPEED + } else { + 1.0 + }; slide.spring = SpringConfig { position_tolerance: 0.00001, velocity_tolerance: 0.00001, diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 9e34c15..f32f12e 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -38,22 +38,22 @@ impl Ferese { ) -> crate::presentation::WindowPresentation { let record = self.windows.record(id).expect("presentation has a window record"); let mut bounds = overview.presented_bounds(id, geometry.visual); - if !overview.is_presenting() { - bounds.current.x += offset.0; - bounds.current.y += offset.1; - if let Some(workspace) = self.workspaces.workspace_for_window(id) - && let Some(output_id) = self.output_workspaces.output_for_workspace(workspace) - && let Some(slide) = self.workspace_slides.get(&output_id) - && let Some(item) = slide.items.iter().find(|item| item.workspace == workspace) - && let Some(area) = self - .outputs_by_id - .get(&output_id) - .and_then(|output| self.space.output_geometry(output)) - { - let (_, velocity) = slide.sample(item, delta); - bounds.velocity.x += velocity.x * f64::from(area.size.w); - bounds.velocity.y += velocity.y * f64::from(area.size.h); - } + bounds.current.x += offset.0; + bounds.current.y += offset.1; + if let Some(workspace) = self.workspaces.workspace_for_window(id) + && let Some((output_id, slide)) = self + .workspace_slides + .iter() + .find(|(_, slide)| slide.contains(workspace)) + && let Some(item) = slide.items.iter().find(|item| item.workspace == workspace) + && let Some(area) = self + .outputs_by_id + .get(output_id) + .and_then(|output| self.space.output_geometry(output)) + { + let (_, velocity) = slide.sample(item, delta); + bounds.velocity.x += velocity.x * f64::from(area.size.w); + bounds.velocity.y += velocity.y * f64::from(area.size.h); } let focused = if self.overview.is_active() { self.overview_selected(id) @@ -339,6 +339,129 @@ fn predict_geometry( mod tests { use super::*; + #[test] + #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] + fn overview_strip_switch_slides_stable_grids_and_keeps_strip_fixed() { + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert!(runtime.starts_with(std::env::temp_dir())); + let mut event_loop = EventLoop::try_new().unwrap(); + let mut state = Ferese::new( + &mut event_loop, + Display::new().unwrap(), + crate::config::Config::default().runtime_config().unwrap(), + ) + .unwrap(); + let output = Output::new( + "overview-slide-test".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (1200, 800).into(), + refresh: 120_000, + }), + Some(smithay::utils::Transform::Normal), + None, + Some((0, 0).into()), + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "overview-slide-test".into()); + let output_id = state.output_id(&output).unwrap(); + let from = state.output_workspaces.active_workspace(output_id).unwrap(); + let to = state.workspaces.create_workspace(); + state.output_workspaces.assign_workspace(output_id, to).unwrap(); + for (id, workspace) in [(WindowId(1), from), (WindowId(2), to)] { + let normal = Rect::new(100., 160., 640., 480.); + state + .workspaces + .insert_floating_window(id, workspace, normal, false) + .unwrap(); + state.windows.records.insert( + id, + super::super::window_registry::WindowRecord { + geometry: Some(WindowGeometry::new(normal, None)), + ..Default::default() + }, + ); + } + state.set_overview_active(true); + state.advance_animations_by(Duration::from_secs(10)); + let old = state.current_window_presentation(WindowId(1)).unwrap().bounds.current; + let cards = state.overview_workspace_cards(&output); + let card = cards.iter().find(|card| card.workspace == to).unwrap(); + assert!( + state.click_overview_workspace( + ( + card.rect.x + card.rect.width * 0.5, + card.rect.y + card.rect.height * 0.5 + ) + .into() + ) + ); + assert!(state.workspace_slides.contains_key(&output_id)); + assert!(state.window_belongs_to_output(WindowId(1), &output)); + let incoming = state.current_window_presentation(WindowId(2)).unwrap().bounds.current; + assert_eq!(incoming.width, old.width); + assert_eq!(incoming.height, old.height); + assert!(incoming.x > old.x + 1000.); + let predicted = state.sample_frame(&output, Duration::from_millis(60)); + assert!(predicted.windows[&WindowId(1)].presentation.bounds.current.x < old.x); + assert!(predicted.windows[&WindowId(2)].presentation.bounds.current.x < incoming.x); + assert_eq!( + predicted.windows[&WindowId(2)].presentation.bounds.current.width, + incoming.width + ); + // Activation can add a trailing empty workspace. Once that layout is + // established, slide prediction must not translate the strip or previews. + let current_cards = state.overview_workspace_cards(&output); + let predicted_cards = state.overview_workspace_cards_for_frame(&output, Some(&predicted)); + for (before, after) in current_cards.iter().zip(predicted_cards) { + assert_eq!(before.rect, after.rect); + assert_eq!(before.windows, after.windows); + } + state.advance_animations_by(Duration::from_millis(80)); + let before = state.workspace_slides[&output_id].clone(); + state.update_workspace_slide(output_id, output_id, Some(to), from, None); + let reversed = &state.workspace_slides[&output_id]; + assert_eq!(reversed.speed, crate::overview::OVERVIEW_MOTION_SPEED); + for item in &before.items { + let after = reversed + .items + .iter() + .find(|other| other.workspace == item.workspace) + .unwrap(); + assert_eq!(item.start, after.start); + assert_eq!(item.velocity, after.velocity); + } + // Finish on the selected workspace and retire all outgoing previews. + state.output_workspaces.switch_workspace(output_id, from).unwrap(); + state.retarget_overview(); + state.advance_animations_by(Duration::from_secs(10)); + assert!(state.workspace_slides.is_empty()); + assert!(!state.overview.has_window_preview(WindowId(2))); + assert!(!state.overview.needs_tick()); + state.animations_enabled = false; + let card = state + .overview_workspace_cards(&output) + .into_iter() + .find(|card| card.workspace == to) + .unwrap(); + state.click_overview_workspace( + ( + card.rect.x + card.rect.width * 0.5, + card.rect.y + card.rect.height * 0.5, + ) + .into(), + ); + assert!(state.workspace_slides.is_empty()); + assert!(!state.overview.needs_tick()); + } + #[test] #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] fn frame_sampling_preserves_compositor_state_between_output_deadlines() { diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index cd702fb..1e8f305 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -9,6 +9,16 @@ use super::{Menu, OpenMenu}; use crate::status::Action; use crate::{FereseShell, Message}; +impl OpenMenu { + fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) { + self.kind = kind; + // Keep the Wayland popup and its material binding, but give the new + // panel its own entrance instead of reusing the settled old panel. + self.motion = crate::motion::PopupMotion::new(settings); + self.motion.begin(now); + } +} + impl FereseShell { pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle) -> Task { if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) { @@ -77,8 +87,7 @@ impl FereseShell { ..Default::default() }; if let Some(menu) = &mut self.menu { - menu.kind = kind; - menu.motion.retarget(1.0, Instant::now()); + menu.switch_panel(kind, self.config.animations, Instant::now()); return Task::batch([ notifications, cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner), @@ -184,3 +193,92 @@ impl FereseShell { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::motion::{PopupMotion, Settings}; + use std::time::Duration; + + #[test] + fn switching_panels_replays_entrance_on_the_same_surface() { + let now = Instant::now(); + let settings = Settings::default(); + let mut menu = OpenMenu { + id: window::Id::unique(), + kind: Menu::Battery, + motion: PopupMotion::new(settings), + effects: None, + regions: Default::default(), + }; + let id = menu.id; + let regions = menu.regions.clone(); + menu.motion.begin(now); + for (index, kind) in [ + Menu::Network, + Menu::Bluetooth, + Menu::Audio, + Menu::Calendar, + Menu::Recording, + Menu::Notifications, + Menu::System, + Menu::Battery, + ] + .into_iter() + .enumerate() + { + let at = now + Duration::from_secs(10 * (index as u64 + 1)); + assert_eq!(menu.motion.progress_at(at), 1.0); + menu.switch_panel(kind, settings, at); + assert_eq!(menu.kind, kind); + assert_eq!(menu.id, id); + assert!(std::sync::Arc::ptr_eq(®ions, &menu.regions)); + assert_eq!(menu.motion.progress_at(at), 0.0); + assert!(menu.motion.frame_active(at)); + let progress = menu.motion.progress_at(at + Duration::from_millis(30)); + assert!(progress > 0.0 && progress < 1.0); + } + let at = now + Duration::from_secs(90); + menu.motion.retarget(0.0, at); + menu.switch_panel(Menu::Network, settings, at + Duration::from_millis(30)); + assert!(!menu.motion.closing()); + assert!(menu.motion.frame_active(at + Duration::from_millis(30))); + } + + #[test] + fn panel_switch_respects_reduced_motion_and_speed() { + let now = Instant::now(); + let mut menu = OpenMenu { + id: window::Id::unique(), + kind: Menu::Battery, + motion: PopupMotion::new(Settings::default()), + effects: None, + regions: Default::default(), + }; + for settings in [ + Settings { + reduced_motion: true, + ..Default::default() + }, + Settings { + enabled: false, + ..Default::default() + }, + ] { + menu.switch_panel(Menu::Network, settings, now); + assert_eq!(menu.motion.progress_at(now), 1.0); + assert!(!menu.motion.frame_active(now)); + } + menu.switch_panel(Menu::Battery, Settings::default(), now); + let normal = menu.motion.progress_at(now + Duration::from_millis(30)); + menu.switch_panel( + Menu::Network, + Settings { + speed: 0.5, + ..Default::default() + }, + now, + ); + assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal); + } +}