diff --git a/Cargo.lock b/Cargo.lock index d276400..1775904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,6 +1675,7 @@ name = "ferese-animation" version = "0.1.0" dependencies = [ "ferese-layout", + "serde", ] [[package]] @@ -1784,6 +1785,7 @@ dependencies = [ name = "ferese-shell" version = "0.1.0" dependencies = [ + "ferese-animation", "ferese-config", "ferese-ipc", "ferese-protocols", diff --git a/README.md b/README.md index d22fc96..37bfe1f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Ferese combines a window manager and desktop shell with one configuration. - Scrolling columns with window grouping, tree tiling and floating windows that remember their size and placement - Workspace overview and recent-window switching - Remappable keyboard shortcuts and touchpad gestures +- [Continuum motion](docs/configuration.md#animations): interruptible springs, gesture momentum and motion settings shared by the compositor and shell - Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery - Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes - Squircle window corners with matching borders and shadows @@ -40,7 +41,7 @@ compares current upstream features and names separate tools where they are neede | Tiling layouts | Scrolling columns and tree tiling; switch per workspace | [Scrolling, dwindle, master and monocle; per-workspace layouts](https://github.com/hyprwm/Hyprland#features) | Scrolling columns | | Floating windows | Built in | Built in | Built in | | Workspace overview | Built in | Plugins such as [HyprExpo](https://github.com/sandwichfarm/hyprexpo) | Built in | -| Animations | Spring and easing animations | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) | +| Animations | [Continuum: interruptible springs, gesture momentum; speed and reduced-motion settings shared by the compositor and shell](docs/configuration.md#animations) | Bézier and spring curves | [Spring and easing animations; custom shaders](https://niri-wm.github.io/niri/Configuration:-Animations.html) | | Live configuration reload | Yes | Yes | Yes | | Monitor mirroring | Built in | [Built in](https://wiki.hypr.land/configuring/core/monitors/) | Separate tool, such as [wl-mirror](https://niri-wm.github.io/niri/Screencasting.html#screen-mirroring) | | Desktop controls | Included Settings and Control Center | [Separate utilities](https://wiki.hypr.land/hypr-ecosystem/) or a desktop shell | [Separate desktop shell or tools](https://niri-wm.github.io/niri/Integrating-niri.html#desktop-components) | diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index f69ba78..a09043c 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -1058,17 +1058,6 @@ fn cursor_affected_outputs(state: &Ferese) -> Vec { .collect() } -#[track_caller] -pub(crate) fn render_window(state: &mut Ferese, window: ferese_layout::WindowId) { - let outputs = state - .space - .outputs() - .filter(|output| state.window_belongs_to_output(window, output)) - .cloned() - .collect::>(); - render_on(state, &outputs); -} - #[track_caller] pub(crate) fn render_surface( state: &mut Ferese, diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 3d0570b..e23fca6 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -346,34 +346,6 @@ const fn default_repeat_delay() -> i32 { 600 } -const fn default_animation_speed() -> f64 { - 1.0 -} - -const fn default_spring_mass() -> f64 { - 1.0 -} - -const fn default_spring_stiffness() -> f64 { - 700.0 -} - -const fn default_spring_damping() -> f64 { - 53.0 -} - -const fn default_viewport_mass() -> f64 { - 1.0 -} - -const fn default_viewport_stiffness() -> f64 { - 320.0 -} - -const fn default_viewport_damping_ratio() -> f64 { - 1.0 -} - const fn default_true() -> bool { true } @@ -384,6 +356,17 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { + #[test] + fn rejects_undamped_overshoot_before_publication() { + for property in ["spring", "viewport-spring"] { + for coefficient in ["damping", "damping-ratio"] { + let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}"); + let error = Config::parse_source(&source).unwrap().runtime_config().err().unwrap().to_string(); + assert!(error.contains("overshoot requires positive damping"), "{error}"); + } + } + } + use super::*; #[test] diff --git a/compositor/src/config/input_motion.rs b/compositor/src/config/input_motion.rs index 881bafd..c02253b 100644 --- a/compositor/src/config/input_motion.rs +++ b/compositor/src/config/input_motion.rs @@ -44,7 +44,7 @@ impl Config { } pub fn animations_enabled(&self) -> bool { - self.animations.enabled && !self.animations.reduced_motion + self.animations.motion_enabled() } pub fn animation_speed(&self) -> Result { @@ -52,39 +52,14 @@ impl Config { } pub fn spring_config(&self) -> Result { - let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?; - let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?; - let damping = self.animations.spring.damping; - if !damping.is_finite() || damping < 0.0 { - return Err(ConfigError::AnimationValue { - field: "spring.damping", - value: damping, - }); - } - - Ok(SpringConfig { - mass, - stiffness, - damping, - ..SpringConfig::default() - }) + self.animations + .spring + .resolve(SpringConfig::default()) + .map_err(ConfigError::Binding) } pub fn viewport_spring_config(&self) -> Result { - let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; - let stiffness = - positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?; - let damping_ratio = positive_animation_value( - self.animations.viewport_spring.damping_ratio, - "viewport_spring.damping_ratio", - )?; - - Ok(SpringConfig { - mass, - stiffness, - damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(), - ..SpringConfig::default() - }) + self.animations.viewport_config().map_err(ConfigError::Binding) } } @@ -178,68 +153,4 @@ impl Default for TouchpadConfig { } } -#[derive(Debug, Deserialize)] -pub(super) struct AnimationsConfig { - #[serde(default = "enabled_by_default")] - pub(super) enabled: bool, - #[serde(default)] - pub(super) reduced_motion: bool, - #[serde(default = "default_animation_speed")] - pub(super) speed: f64, - #[serde(default)] - pub(super) spring: SpringSettings, - #[serde(default)] - pub(super) viewport_spring: ViewportSpringSettings, -} - -impl Default for AnimationsConfig { - fn default() -> Self { - Self { - enabled: true, - reduced_motion: false, - speed: 1.0, - spring: SpringSettings::default(), - viewport_spring: ViewportSpringSettings::default(), - } - } -} - -#[derive(Debug, Deserialize)] -pub(super) struct ViewportSpringSettings { - #[serde(default = "default_viewport_mass")] - pub(super) mass: f64, - #[serde(default = "default_viewport_stiffness")] - pub(super) stiffness: f64, - #[serde(default = "default_viewport_damping_ratio")] - pub(super) damping_ratio: f64, -} - -impl Default for ViewportSpringSettings { - fn default() -> Self { - Self { - mass: default_viewport_mass(), - stiffness: default_viewport_stiffness(), - damping_ratio: default_viewport_damping_ratio(), - } - } -} - -#[derive(Debug, Deserialize)] -pub(super) struct SpringSettings { - #[serde(default = "default_spring_mass")] - pub(super) mass: f64, - #[serde(default = "default_spring_stiffness")] - pub(super) stiffness: f64, - #[serde(default = "default_spring_damping")] - pub(super) damping: f64, -} - -impl Default for SpringSettings { - fn default() -> Self { - Self { - mass: default_spring_mass(), - stiffness: default_spring_stiffness(), - damping: default_spring_damping(), - } - } -} +pub(super) type AnimationsConfig = ferese_animation::MotionSettings; diff --git a/compositor/src/gestures.rs b/compositor/src/gestures.rs index daf686e..2391e97 100644 --- a/compositor/src/gestures.rs +++ b/compositor/src/gestures.rs @@ -1,5 +1,3 @@ -//! Navigation previews follow finger motion; actions commit once on release. - #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[repr(u32)] pub(crate) enum SwipeDirection { @@ -12,10 +10,16 @@ pub(crate) enum SwipeDirection { #[derive(Default)] pub(crate) struct Swipe { delta: Option<(f64, f64)>, + tracker_x: ferese_animation::gesture::VelocityTracker, + tracker_y: ferese_animation::gesture::VelocityTracker, + pub release_velocity: f64, + pub unbounded_release_velocity: f64, + threshold: f64, fingers: u32, direction: Option, preview_started: bool, + momentum_navigation: bool, } impl Swipe { @@ -25,6 +29,59 @@ impl Swipe { self.fingers = fingers; self.direction = None; self.preview_started = false; + self.momentum_navigation = false; + self.tracker_x = Default::default(); + self.tracker_y = Default::default(); + self.release_velocity = 0.0; + self.unbounded_release_velocity = 0.0; + } + + pub fn start_time(&mut self, time: std::time::Duration) { + self.tracker_x.push(time, 0.0); + self.tracker_y.push(time, 0.0); + } + + pub fn update_at(&mut self, dx: f64, dy: f64, time: std::time::Duration) { + self.update(dx, dy); + if let Some((x, y)) = self.delta { + self.tracker_x.push(time, x); + self.tracker_y.push(time, y); + } + } + + pub fn finish_at(&mut self, cancelled: bool, time: std::time::Duration) -> Option { + let Some(direction) = self.direction else { + return self.finish(cancelled); + }; + let v = match direction { + SwipeDirection::Left => -self.tracker_x.velocity(time), + SwipeDirection::Right => self.tracker_x.velocity(time), + SwipeDirection::Up => -self.tracker_y.velocity(time), + SwipeDirection::Down => self.tracker_y.velocity(time), + }; + self.release_velocity = if cancelled { + 0.0 + } else { + v / (2.0 * self.threshold.max(1.0)) + }; + self.unbounded_release_velocity = self.release_velocity; + if !self.preview_started && !self.momentum_navigation { + return self.finish(cancelled); + } + let Some((_, progress)) = self.preview() else { + self.release_velocity = 0.0; + self.unbounded_release_velocity = 0.0; + self.delta = None; + return None; + }; + // The preview is bounded. Its visible velocity is zero when pushing + // farther beyond either endpoint, even if the fingers keep moving. + if (progress == 0.0 && self.release_velocity < 0.0) || (progress == 1.0 && self.release_velocity > 0.0) { + self.release_velocity = 0.0; + } + self.delta = None; + (!cancelled && ferese_animation::gesture::project(progress, self.release_velocity, 0.997) >= 0.5) + .then_some(direction) } pub fn fingers(&self) -> u32 { @@ -49,11 +106,20 @@ impl Swipe { self.preview_started = true; } + pub fn mark_momentum_navigation(&mut self) { + self.momentum_navigation = true; + } + pub fn preview_started(&self) -> bool { self.preview_started } pub fn preview(&self) -> Option<(SwipeDirection, f64)> { + self.unbounded_preview() + .map(|(direction, progress)| (direction, progress.clamp(0.0, 1.0))) + } + + pub fn unbounded_preview(&self) -> Option<(SwipeDirection, f64)> { let (x, y) = self.delta?; if !x.is_finite() || !y.is_finite() { return None; @@ -65,7 +131,7 @@ impl Swipe { SwipeDirection::Up => -y, SwipeDirection::Down => y, }; - Some((direction, (distance / (self.threshold.max(1.0) * 2.0)).clamp(0.0, 1.0))) + Some((direction, distance / (self.threshold.max(1.0) * 2.0))) } pub fn finish(&mut self, cancelled: bool) -> Option { @@ -103,6 +169,47 @@ fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option mod tests { use super::*; + #[test] + fn release_uses_momentum_and_a_pause_removes_it() { + use std::time::Duration; + let swipe = || { + let mut swipe = Swipe::default(); + swipe.begin(3, false, 80); + swipe.start_time(Duration::ZERO); + swipe.update_at(0.0, -20.0, Duration::from_millis(10)); + swipe.update_at(0.0, -20.0, Duration::from_millis(20)); + swipe.mark_preview_started(); + swipe + }; + let mut flick = swipe(); + assert_eq!(flick.preview(), Some((SwipeDirection::Up, 0.25))); + assert_eq!( + flick.finish_at(false, Duration::from_millis(20)), + Some(SwipeDirection::Up) + ); + assert!((flick.release_velocity - 12.5).abs() < 1e-9); + let mut paused = swipe(); + assert_eq!(paused.finish_at(false, Duration::from_millis(80)), None); + assert_eq!(paused.release_velocity, 0.0); + let mut cancelled = swipe(); + assert_eq!(cancelled.finish_at(true, Duration::from_millis(20)), None); + assert_eq!(cancelled.release_velocity, 0.0); + } + + #[test] + fn reversing_momentum_can_cancel_past_halfway() { + use std::time::Duration; + let mut swipe = Swipe::default(); + swipe.begin(3, false, 80); + swipe.start_time(Duration::ZERO); + swipe.update_at(0.0, -120.0, Duration::from_millis(200)); + swipe.update_at(0.0, 20.0, Duration::from_millis(210)); + swipe.mark_preview_started(); + assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.625))); + assert_eq!(swipe.finish_at(false, Duration::from_millis(210)), None); + assert!(swipe.release_velocity < 0.0); + } + #[test] fn cancellation_and_new_gestures_reset_preview_state() { let mut swipe = Swipe::default(); diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 1dc3198..5748a7f 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -37,6 +37,7 @@ impl CompositorHandler for Ferese { fn commit(&mut self, surface: &WlSurface) { self.update_surface_preferences(surface); self.capture_resize_before_commit(surface); + self.capture_close_before_commit(surface); on_commit_buffer_handler::(surface); if !is_sync_subsurface(surface) { let mut root = surface.clone(); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index da5c175..ed1a93e 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -170,6 +170,7 @@ impl Ferese { .any(|binding| binding.swipe_fingers(event.fingers())), self.input_settings.touchpad.swipe_threshold, ); + self.swipe.start_time(std::time::Duration::from_micros(event.time())); if self.swipe.active() { self.focus_output_at(pointer.current_location()); } else { @@ -185,7 +186,11 @@ impl Ferese { } InputEvent::GestureSwipeUpdate { event } => { if self.swipe.active() { - self.swipe.update(event.delta_x(), event.delta_y()); + self.swipe.update_at( + event.delta_x(), + event.delta_y(), + std::time::Duration::from_micros(event.time()), + ); if self.swipe_navigation_blocked() { self.finish_workspace_swipe(None); self.finish_focus_swipe(None); @@ -198,10 +203,13 @@ impl Ferese { { match action { BindingAction::SwitchRelativeWorkspace(next) => { + self.swipe.mark_momentum_navigation(); self.preview_workspace_swipe(next, direction, progress); } BindingAction::Focus(focus @ (Direction::Left | Direction::Right)) => { - self.preview_focus_swipe(focus, direction, progress); + self.swipe.mark_momentum_navigation(); + let raw = self.swipe.unbounded_preview().map_or(progress, |(_, value)| value); + self.preview_focus_swipe(focus, direction, raw); } _ => {} } @@ -220,7 +228,9 @@ impl Ferese { if self.swipe.active() { let fingers = self.swipe.fingers(); let cancelled = event.cancelled() || self.swipe_navigation_blocked(); - let direction = self.swipe.finish(cancelled); + let direction = self + .swipe + .finish_at(cancelled, std::time::Duration::from_micros(event.time())); let workspace_handled = self.finish_workspace_swipe(direction); let focus_handled = self.finish_focus_swipe(direction); let handled = workspace_handled || focus_handled || self.swipe.preview_started(); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 6448940..4dd73cd 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -17,7 +17,6 @@ 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; -const EXIT_DURATION: f64 = 0.18; const WORKSPACE_CARD_GAP: f64 = 8.0; const MAX_PREVIEW_SCALE: f64 = 0.82; @@ -117,7 +116,6 @@ pub(crate) struct OverviewMotion { active: bool, presentations: HashMap, opacity: AnimatedValue, - exit_transition: Option<(f64, f64)>, } impl Default for OverviewState { @@ -179,8 +177,7 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 { impl OverviewState { pub(crate) fn needs_tick(&self) -> bool { - self.motion.exit_transition.is_some() - || self.motion.opacity.current != self.motion.opacity.target + self.motion.opacity.current != self.motion.opacity.target || self.motion.opacity.velocity != 0.0 || self .motion @@ -366,25 +363,22 @@ impl OverviewState { self.motion.presented_rect(id, normal) } + pub(crate) fn presented_velocity(&self, id: WindowId) -> Option { + let velocity = self.motion.presentations.get(&id)?.velocity; + Some(ferese_animation::RectVelocity { + x: velocity.x * OVERVIEW_MOTION_SPEED, + y: velocity.y * OVERVIEW_MOTION_SPEED, + width: velocity.width * OVERVIEW_MOTION_SPEED, + height: velocity.height * OVERVIEW_MOTION_SPEED, + }) + } + fn enter( &mut self, targets: HashMap, selected: Option, animations_enabled: bool, ) { - // Reverse a dismissal from the currently visible geometry, not the old grid. - if self.motion.exit_transition.is_some() { - let visible: Vec<_> = targets - .iter() - .map(|(id, (normal, _))| (*id, self.presented_rect(*id, *normal))) - .collect(); - for (id, rect) in visible { - if let Some(presentation) = self.motion.presentations.get_mut(&id) { - *presentation = AnimatedRect::new(rect); - } - } - } - self.motion.exit_transition = None; self.selection_workspace = None; self.motion.active = true; self.motion.opacity.retarget_preserving_motion(1.0); @@ -423,7 +417,6 @@ impl OverviewState { fn exit(&mut self, normal: HashMap, animations_enabled: bool) { self.motion.active = false; - self.motion.exit_transition = animations_enabled.then_some((0.0, self.motion.opacity.current)); self.motion.opacity.retarget_preserving_motion(0.0); if !animations_enabled { self.motion.opacity.snap(); @@ -605,6 +598,17 @@ impl Ferese { pub(crate) fn retarget_overview(&mut self) { if !self.overview.is_active() { + if self.overview.is_presenting() { + let normal = self.normal_window_rects(); + self.overview.motion.presentations.retain(|id, presentation| { + if let Some(target) = normal.get(id) { + presentation.set_target(*target); + true + } else { + false + } + }); + } return; } @@ -643,17 +647,26 @@ impl Ferese { presented.x += x; presented.y += y; } - Some(presented) + let record = self.windows.record(id)?; + let opening = record.opening.map_or(1.0, |value| value.current); + Some(crate::presentation::scaled_visual_rect( + presented, + 0.97 + 0.03 * opening, + )) } pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> { let geometry = self.windows.geometry(&id)?; - if !self.overview.is_presenting() { + if !self.overview.is_presenting() && self.windows.record(id)?.opening.is_none() { let presented = self.presented_window_rect(id)?; return Some((x - presented.x, y - presented.y)); } - let source = geometry.client.committed_size?; - let presented = self.overview.presented_rect(id, geometry.visual.current); + let source = if self.overview.is_presenting() { + geometry.client.committed_size? + } else { + ferese_animation::ClientSize::from_rect(geometry.visual.current) + }; + let presented = self.presented_window_rect(id)?; if source.width <= 0 || source.height <= 0 || presented.width <= 0.0 || presented.height <= 0.0 { return None; @@ -989,15 +1002,13 @@ impl Default for OverviewMotion { active: false, presentations: HashMap::new(), opacity: AnimatedValue::new(0.0), - exit_transition: None, } } } impl OverviewMotion { pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool { - self.exit_transition.is_some() - || (self.opacity.current - self.opacity.target).abs() > 0.001 + (self.opacity.current - self.opacity.target).abs() > 0.001 || self.opacity.velocity.abs() > 0.005 || self.presentations.values().any(|rect| !rect.is_settled(spring)) } @@ -1012,43 +1023,17 @@ impl OverviewMotion { pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect { let preview = self.presentations.get(&id).map_or(normal, |p| p.current); - if let Some((elapsed, _)) = self.exit_transition { - let t = 1.0 - (1.0 - (elapsed / EXIT_DURATION).clamp(0.0, 1.0)).powi(3); - return Rect::new( - preview.x + (normal.x - preview.x) * t, - preview.y + (normal.y - preview.y) * t, - preview.width + (normal.width - preview.width) * t, - preview.height + (normal.height - preview.height) * t, - ); - } preview } pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool { let delta = delta.mul_f64(OVERVIEW_MOTION_SPEED); - if let Some((elapsed, initial_opacity)) = self.exit_transition { - let elapsed = if animations_enabled { - elapsed + delta.as_secs_f64() - } else { - EXIT_DURATION - }; - let remaining = (1.0 - elapsed / EXIT_DURATION).clamp(0.0, 1.0); - self.opacity.current = initial_opacity * remaining.powi(3); - if elapsed >= EXIT_DURATION { - self.exit_transition = None; - self.presentations.clear(); - self.opacity.snap(); - return false; - } - self.exit_transition = Some((elapsed, initial_opacity)); - return true; - } let mut active_animation = false; for presentation in self.presentations.values_mut() { if animations_enabled { - active_animation |= presentation.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); + active_animation |= presentation.advance(delta, spring); } else { presentation.snap(); } @@ -1197,7 +1182,7 @@ mod tests { let before = overview.presented_rect(id, normal); let opacity = overview.opacity(); - let finished = overview.sample(Duration::from_millis(24), SpringConfig::default()); + let finished = overview.sample(Duration::from_secs(2), SpringConfig::default()); let earlier = overview.sample(Duration::from_millis(4), SpringConfig::default()); assert!(!finished.is_presenting()); @@ -1351,7 +1336,7 @@ mod tests { } #[test] - fn exiting_overview_tracks_a_moving_layout_and_finishes_in_300ms() { + fn exiting_overview_retargets_without_resetting_motion() { let id = WindowId(1); let initial = Rect::new(-400.0, 0.0, 800.0, 600.0); let preview = Rect::new(100.0, 100.0, 400.0, 300.0); @@ -1359,10 +1344,15 @@ mod tests { let mut overview = OverviewState::default(); overview.enter(HashMap::from([(id, (initial, preview))]), Some(id), false); overview.exit(HashMap::from([(id, initial)]), true); - overview.advance(Duration::from_millis(150), SpringConfig::default(), true); - let shown = overview.presented_rect(id, moved); - assert!((shown.x - 187.5).abs() < 0.001); - overview.advance(Duration::from_millis(150), SpringConfig::default(), true); + overview.advance(Duration::from_millis(60), SpringConfig::default(), true); + let before = overview.motion.presentations[&id]; + overview.exit(HashMap::from([(id, moved)]), true); + let after = overview.motion.presentations[&id]; + assert_eq!(before.current, after.current); + assert_eq!(before.velocity, after.velocity); + for _ in 0..300 { + overview.advance(Duration::from_millis(8), SpringConfig::default(), true); + } assert!(!overview.is_presenting()); assert_eq!(overview.presented_rect(id, moved), moved); } @@ -1378,7 +1368,9 @@ mod tests { overview.exit(HashMap::from([(id, normal)]), true); overview.advance(Duration::from_millis(60), SpringConfig::default(), true); let shown = overview.presented_rect(id, normal); + let velocity = overview.motion.presentations[&id].velocity; overview.enter(targets, Some(id), true); + assert_eq!(overview.motion.presentations[&id].velocity, velocity); assert_eq!(overview.presented_rect(id, normal), shown); } diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index ebab9ea..da39825 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -523,3 +523,63 @@ mod tests { assert!(handoff_alpha(Duration::from_millis(1)) > 0.99); } } + +pub(crate) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { + let scale = scale.max(0.0); + let width = rect.width * scale; + let height = rect.height * scale; + + ferese_layout::Rect::new( + rect.x + (rect.width - width) / 2.0, + rect.y + (rect.height - height) / 2.0, + width, + height, + ) +} + +pub(crate) fn scaled_visual_velocity( + rect: ferese_layout::Rect, + velocity: ferese_animation::RectVelocity, + scale: f64, + scale_velocity: f64, +) -> ferese_animation::RectVelocity { + ferese_animation::RectVelocity { + x: velocity.x + ((1.0 - scale) * velocity.width - rect.width * scale_velocity) * 0.5, + y: velocity.y + ((1.0 - scale) * velocity.height - rect.height * scale_velocity) * 0.5, + width: velocity.width * scale + rect.width * scale_velocity, + height: velocity.height * scale + rect.height * scale_velocity, + } +} + +#[cfg(test)] +mod motion_tests { + use super::*; + #[test] + fn center_scale_velocity_matches_the_visible_rectangle() { + let rect = ferese_layout::Rect::new(12.25, 18.75, 400.5, 300.25); + let velocity = ferese_animation::RectVelocity { + x: 80.0, + y: -40.0, + width: 100.0, + height: 20.0, + }; + let v = scaled_visual_velocity(rect, velocity, 0.98, 0.07); + let before = scaled_visual_rect(rect, 0.98); + let dt = 1e-5; + let next = ferese_layout::Rect::new( + rect.x + velocity.x * dt, + rect.y + velocity.y * dt, + rect.width + velocity.width * dt, + rect.height + velocity.height * dt, + ); + let after = scaled_visual_rect(next, 0.98 + 0.07 * dt); + for (measured, expected) in [ + ((after.x - before.x) / dt, v.x), + ((after.y - before.y) / dt, v.y), + ((after.width - before.width) / dt, v.width), + ((after.height - before.height) / dt, v.height), + ] { + assert!((measured - expected).abs() < 0.0001); + } + } +} diff --git a/compositor/src/render/closing.rs b/compositor/src/render/closing.rs new file mode 100644 index 0000000..d5224ac --- /dev/null +++ b/compositor/src/render/closing.rs @@ -0,0 +1,191 @@ +use super::*; +use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig}; +use ferese_core::OutputId; +use ferese_layout::WindowId; + +/// Detached presentation only: no live window, input, focus, or layout entry. +#[derive(Clone)] +pub(crate) struct ClosedWindow { + pub id: WindowId, + pub output: OutputId, + pub below: Option, + pub bounds: AnimatedRect, + pub opacity: AnimatedValue, + pub snapshot: ResizeSnapshot, + pub source: Rectangle, + pub radius: f64, + pub shape: CornerShape, + pub focus: f64, + pub decorations: f64, + pub dim: f64, + pub fill: Option<[f32; 4]>, + pub material: Option<( + smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + crate::effects::SemanticRole, + u64, + f32, + )>, +} + +impl ClosedWindow { + pub fn advance(&mut self, delta: Duration, spring: SpringConfig) -> bool { + self.bounds.advance(delta, spring); + let opacity = self.opacity.advance_with_policy( + delta, + SpringConfig { + position_tolerance: 0.001, + velocity_tolerance: 0.001, + ..spring + }, + CrossingPolicy::NoCrossing, + ); + self.snapshot.commit.increment(); + // Once invisible there is no reason to keep a texture (including with + // intentionally undamped, overshooting geometry configurations). + opacity + } +} + +pub(super) fn elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + below: Option, + live: &std::collections::HashSet, + delta: Duration, +) -> Vec { + if state.session_lock.active() { + return Vec::new(); + } + let Some(output_id) = state.output_id(output) else { + return Vec::new(); + }; + let Some(output_geometry) = state.space.output_geometry(output) else { + return Vec::new(); + }; + let scale = output.current_scale().fractional_scale(); + let windows: Vec<_> = state + .render + .closing + .iter() + .filter(|window| { + window.output == output_id + && window.below.filter(|id| live.contains(id)) == below + && window.snapshot.context == renderer.context_id().erased() + }) + .cloned() + .collect(); + let mut elements = Vec::new(); + for mut window in windows { + if !delta.is_zero() { + window.advance(delta, state.spring_config); + } + let visual = window.bounds.current; + let corners = RoundedRect::new(visual, output_geometry.loc, scale, window.radius).with_shape(window.shape); + let constrain = rounded_visual_rect(visual, output_geometry.loc); + let Some(programs) = corner_program(&mut state.render, renderer, window.shape) else { + continue; + }; + let alpha = window.opacity.current.clamp(0.0, 1.0) as f32; + if let Some(dim) = window_tint_element( + &mut state.render, + renderer, + window.id, + constrain, + corners, + [0.0, 0.0, 0.0, window.dim as f32 * alpha], + false, + output, + ) { + elements.push(dim.into()); + } + let theme = &state.theme_settings; + if let Some(border) = window_border_element( + &mut state.render, + theme, + renderer, + window.id, + constrain, + corners, + scale, + theme.border_width + (theme.focus_ring_width - theme.border_width) * window.focus, + theme.border_color.0, + theme.border_gradient, + window.focus as f32, + alpha * window.decorations as f32, + output, + &programs, + ) { + elements.push(border.into()); + } + let clip = framebuffer_clip_rect( + corners.rect, + output.current_mode().unwrap().size, + output.current_transform().invert(), + ); + elements.push( + NativeTextureElement { + id: window.snapshot.id.clone(), + commit: window.snapshot.commit, + texture: window.snapshot.texture.clone(), + geometry: corners.rect, + source: window.source, + alpha, + program: Some(programs.texture.clone()), + uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)], + } + .into(), + ); + if let Some(mut fill) = window.fill { + fill[3] = alpha; + if let Some(fill) = window_tint_element( + &mut state.render, + renderer, + window.id, + constrain, + corners, + fill, + true, + output, + ) { + elements.push(fill.into()); + } + } + if let Some((surface, role, generation, opacity)) = &window.material { + if let Some((material, _)) = material_element_with_role( + state, + renderer, + output, + surface, + MaterialSurface { + geometry: constrain, + corners, + index: 0, + capture_geometry: constrain, + alpha, + }, + (*role, *generation, *opacity), + ) { + elements.push(material); + } + } + let theme = &state.theme_settings; + if let Some(shadow) = window_shadow_element( + &mut state.render, + renderer, + window.id, + constrain, + corners, + scale, + theme.shadow_offset_y, + theme.shadow_blur, + theme.shadow_opacity * f64::from(alpha) * window.decorations, + theme.shadow_color.0, + output, + &programs, + ) { + elements.push(shadow.into()); + } + } + elements +} diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs index 213c799..832e37d 100644 --- a/compositor/src/render/decorations.rs +++ b/compositor/src/render/decorations.rs @@ -380,6 +380,7 @@ pub(super) fn rounded_window_elements( output: &Output, programs: RoundedClipPrograms, behavior: ConstrainScaleBehavior, + native_size: Option, ) -> Vec { let Some(toplevel) = window.toplevel() else { return Vec::new(); @@ -390,7 +391,13 @@ pub(super) fn rounded_window_elements( let physical_constrain = corners.rect; let geometry = window.geometry(); - let reference = geometry.to_physical_precise_round(scale); + let mut reference = geometry.to_physical_precise_round(scale); + let behavior = if let Some(size) = native_size { + reference.size = Size::::from((size.width, size.height)).to_physical_precise_round(scale); + ConstrainScaleBehavior::Stretch + } else { + behavior + }; let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert()); let radius = corners.radius; @@ -462,18 +469,8 @@ pub(super) fn resize_content_behavior(intentional_scale: bool) -> ConstrainScale } } -pub(super) fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { - let scale = scale.clamp(0.0, 1.0); - let width = rect.width * scale; - let height = rect.height * scale; - - ferese_layout::Rect::new( - rect.x + (rect.width - width) / 2.0, - rect.y + (rect.height - height) / 2.0, - width, - height, - ) -} +#[cfg(test)] +pub(super) use crate::presentation::scaled_visual_rect; pub(super) fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] { color[3] *= alpha; diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index b68fa3c..e758b7c 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -128,6 +128,25 @@ pub(super) fn material_element( output: &Output, surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, surface_geometry: MaterialSurface, +) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { + let (role, generation) = crate::effects::surface_role(surface)?; + material_element_with_role( + state, + renderer, + output, + surface, + surface_geometry, + (role, generation, crate::effects::surface_opacity(surface)), + ) +} + +pub(super) fn material_element_with_role( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + surface_geometry: MaterialSurface, + (role, generation, opacity): (crate::effects::SemanticRole, u64, f32), ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { let MaterialSurface { geometry, @@ -136,13 +155,12 @@ pub(super) fn material_element( capture_geometry, alpha, } = surface_geometry; - let (role, generation) = crate::effects::surface_role(surface)?; let material = crate::effects::resolve_material( role, state.theme_settings.material_style, state.theme_settings.shell_opacity as f32, ); - let presentation_alpha = crate::effects::surface_opacity(surface) * alpha; + let presentation_alpha = opacity * alpha; let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); let transform = output.current_transform().invert(); diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index 4b9e325..b9fcf61 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -1,5 +1,7 @@ mod backdrop; mod capture; +mod closing; +pub(crate) use closing::ClosedWindow; #[cfg(test)] mod corner_tests; mod decorations; @@ -78,7 +80,7 @@ render_elements! { Native=NativeTextureElement, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct ResizeSnapshot { pub texture: GlesTexture, pub context: ErasedContextId, @@ -134,7 +136,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String { } } -fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape { +pub(crate) fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape { // Semantic shell surfaces keep their client-matching circular outlines. corner_shape_for_role( window diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 6d149e4..626d376 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -241,6 +241,7 @@ pub(super) fn overview_strip_elements( output, programs.clone(), ConstrainScaleBehavior::Stretch, + None, )); } } diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index 8cd7a01..13bc914 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -8,6 +8,7 @@ use super::*; /// context. Desktop policy and animation values do not live in these caches. #[derive(Default)] pub(crate) struct RenderResources { + pub closing: Vec, pub(super) windows: HashMap, pub(super) contexts: HashMap, pub(super) outputs: HashMap, @@ -71,6 +72,16 @@ impl RenderResources { } pub fn remove_output(&mut self, id: OutputId) { + let removed: Vec<_> = self + .closing + .iter() + .filter(|window| window.output == id) + .map(|window| window.id) + .collect(); + self.closing.retain(|window| window.output != id); + for id in removed { + self.remove_window(id); + } self.outputs.remove(&id); for window in self.windows.values_mut() { @@ -124,6 +135,16 @@ impl RenderResources { } pub fn forget_context(&mut self, context: &ErasedContextId) { + let removed: Vec<_> = self + .closing + .iter() + .filter(|window| &window.snapshot.context == context) + .map(|window| window.id) + .collect(); + self.closing.retain(|window| &window.snapshot.context != context); + for id in removed { + self.remove_window(id); + } self.contexts.remove(context); for window in self.windows.values_mut() { diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index 307e31a..d4510aa 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -124,8 +124,8 @@ pub(crate) fn sampled_output_elements( return None; } let sample = frame.windows.get(&id)?; - let visual = scaled_visual_rect(sample.rect, sample.close_scale); - let close_alpha = sample.close_alpha; + let visual = sample.rect; + let close_alpha = sample.alpha; let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0); Some((window.clone(), id, sample, visual, decoration_progress, close_alpha)) @@ -145,7 +145,16 @@ pub(crate) fn sampled_output_elements( .collect::>() }; + let live = windows.iter().map(|(_, id, ..)| *id).collect(); for (window, id, sample, visual, decoration_progress, close_alpha) in windows { + elements.extend(super::closing::elements( + state, + renderer, + output, + Some(id), + &live, + frame.delta, + )); let constrain = rounded_visual_rect(visual, output_geometry.loc); let material_surface = window .toplevel() @@ -165,7 +174,7 @@ pub(crate) fn sampled_output_elements( let rounded_clip_program = corner_program(&mut state.render, renderer, shape); let pixels = corners.rect; // Only overview/close intentionally scale the complete application. - let scale_content = frame.overview.is_presenting() || sample.close_scale != 1.0; + let scale_content = sample.scale_content; let behavior = resize_content_behavior(scale_content); let dim = sample.dim; @@ -227,13 +236,33 @@ pub(crate) fn sampled_output_elements( output, &programs, ); - if !scale_content - && let Some(snapshot) = state.render.snapshot(&id) + if let Some(snapshot) = state.render.snapshot(&id) && snapshot.context == renderer.context_id().erased() && (snapshot.scale - scale).abs() < 0.001 { let size = snapshot.texture.size(); - let visible = Rectangle::new(pixels.loc, (size.w, size.h).into()).intersection(pixels); + let (sx, sy) = if scale_content { + ( + f64::from(pixels.size.w) / f64::from(size.w), + f64::from(pixels.size.h) / f64::from(size.h), + ) + } else if let Some(native) = sample.native_size { + ( + f64::from(pixels.size.w) / (f64::from(native.width) * scale).round().max(1.0), + f64::from(pixels.size.h) / (f64::from(native.height) * scale).round().max(1.0), + ) + } else { + (1.0, 1.0) + }; + let visible = Rectangle::new( + pixels.loc, + ( + (f64::from(size.w) * sx).round() as i32, + (f64::from(size.h) * sy).round() as i32, + ) + .into(), + ) + .intersection(pixels); if let Some(visible) = visible { let clip = framebuffer_clip_rect( pixels, @@ -247,8 +276,8 @@ pub(crate) fn sampled_output_elements( texture: snapshot.texture.clone(), geometry: visible, source: Rectangle::from_size(Size::from(( - f64::from(visible.size.w), - f64::from(visible.size.h), + f64::from(visible.size.w) / sx, + f64::from(visible.size.h) / sy, ))), alpha: crate::presentation::handoff_alpha(snapshot.elapsed) * close_alpha, program: Some(programs.texture.clone()), @@ -268,6 +297,7 @@ pub(crate) fn sampled_output_elements( output, programs.clone(), behavior, + sample.native_size, )); let source = window.geometry().size; if material_surface.is_none() @@ -322,6 +352,14 @@ pub(crate) fn sampled_output_elements( )); } } + elements.extend(super::closing::elements( + state, + renderer, + output, + None, + &live, + frame.delta, + )); elements.extend(layer_elements( state, renderer, @@ -374,7 +412,14 @@ pub(super) fn layer_elements( .collect::>() }; - state.render.surfaces.retain(|surface, _| surface.is_alive()); + state.render.surfaces.retain(|surface, _| { + surface.is_alive() + || state + .render + .closing + .iter() + .any(|window| window.material.as_ref().is_some_and(|(key, ..)| key == surface)) + }); let mut elements = Vec::new(); for (geometry, layer) in layers { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index bf0d32a..d49ffb2 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -9,6 +9,7 @@ mod reconciliation; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; mod prediction; pub(crate) use prediction::FrameScene; +mod lifecycle; mod window_registry; mod windows; @@ -19,7 +20,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use calloop::LoopHandle; -use ferese_animation::{AnimatedValue, ClientSize, CrossingPolicy, PresentationMode, SpringConfig, WindowGeometry}; +use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry}; use ferese_core::{ LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, WorkspaceLayout, WorkspaceSet, @@ -98,9 +99,11 @@ use crate::wallpaper::{WallpaperConfig, WallpaperState}; use crate::window_rules::{WindowRule, resolve as resolve_window_rules}; use crate::winit::NestedBackend; -const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140); -const WORKSPACE_SLIDE_DURATION: Duration = Duration::from_millis(220); -const WORKSPACE_SLIDE_FIRST_FRAME: Duration = Duration::from_millis(8); +#[cfg(test)] +use ferese_animation::CrossingPolicy; + +#[cfg(test)] +const SLIDE_SETTLE_TEST_BUDGET: Duration = Duration::from_secs(2); fn workspace_swipe_is_current( outputs: &OutputWorkspaceMap, @@ -153,7 +156,7 @@ struct FocusSwipe { } impl FocusSwipe { - fn position(&self) -> f64 { + fn distance(&self) -> f64 { let distance = self.destination - self.start; // When both columns already fit, give bounded drag feedback and settle // back to the unchanged viewport after selecting the next window. @@ -166,7 +169,29 @@ impl FocusSwipe { } else { distance }; - self.start + distance * self.progress + distance + } + + fn position(&self) -> f64 { + let progress = if self.to == self.from { + ferese_animation::gesture::rubber_band(self.progress.max(0.0), 1.0, 0.55).0 + } else { + self.progress.clamp(0.0, 1.0) + }; + self.start + self.distance() * progress + } + + fn release_velocity(&self, bounded: f64, unbounded: f64) -> f64 { + let velocity = if self.to == self.from { + if self.progress <= 0.0 { + 0.0 + } else { + unbounded * ferese_animation::gesture::rubber_band(self.progress, 1.0, 0.55).1 + } + } else { + bounded + }; + self.distance() * velocity } } @@ -175,12 +200,13 @@ struct WorkspaceSlideItem { workspace: WorkspaceId, start: SlideOffset, target: SlideOffset, + velocity: SlideOffset, } #[derive(Clone)] struct WorkspaceSlide { items: Vec, - elapsed: Duration, + spring: SpringConfig, held_progress: Option, gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>, } @@ -190,12 +216,14 @@ impl WorkspaceSlide { let movement = SlideOffset::for_swipe(direction); let mut items = previous .map(|slide| { - let progress = slide.progress(); + let progress = slide.held_progress; slide .items .into_iter() .map(|mut item| { - item.start = item.start.between(item.target, progress); + if let Some(progress) = progress { + item.start = item.start.between(item.target, progress); + } item }) .collect::>() @@ -209,6 +237,7 @@ impl WorkspaceSlide { workspace: from, start: SlideOffset::default(), target: movement, + velocity: SlideOffset::default(), }); } if let Some(item) = items.iter_mut().find(|item| item.workspace == to) { @@ -218,30 +247,64 @@ impl WorkspaceSlide { workspace: to, start: movement.opposite(), target: SlideOffset::default(), + velocity: SlideOffset::default(), }); } Self { items, - elapsed: WORKSPACE_SLIDE_FIRST_FRAME, + spring: SpringConfig { + stiffness: 320.0, + damping: 2.0 * 320.0_f64.sqrt(), + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..SpringConfig::default() + }, held_progress: None, gesture: None, } } - fn progress(&self) -> f64 { - self.progress_at(Duration::ZERO) + fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset { + if let Some(progress) = self.held_progress { + return item.start.between(item.target, progress); + } + let mut x = AnimatedValue { + current: item.start.x, + target: item.target.x, + velocity: item.velocity.x, + }; + let mut y = AnimatedValue { + current: item.start.y, + target: item.target.y, + velocity: item.velocity.y, + }; + x.advance(delta, self.spring); + y.advance(delta, self.spring); + SlideOffset { + x: x.current, + y: y.current, + } } - fn progress_at(&self, delta: Duration) -> f64 { - self.held_progress.unwrap_or_else(|| { - let elapsed = self.elapsed.saturating_add(delta).min(WORKSPACE_SLIDE_DURATION); - smoothstep(elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64()) - }) + fn moving(&self) -> bool { + self.items + .iter() + .any(|item| item.start != item.target || item.velocity != SlideOffset::default()) + } + + fn release_with_velocity(&mut self, committed: bool, velocity: f64) { + for item in &mut self.items { + item.velocity = SlideOffset { + x: (item.target.x - item.start.x) * velocity, + y: (item.target.y - item.start.y) * velocity, + }; + } + self.release(committed); } fn release(&mut self, committed: bool) { - let progress = self.progress(); + let progress = self.held_progress.unwrap_or(0.0); for item in &mut self.items { let position = item.start.between(item.target, progress); if !committed && let Some((from, to, direction)) = self.gesture { @@ -255,12 +318,36 @@ impl WorkspaceSlide { } self.held_progress = None; self.gesture = None; - self.elapsed = Duration::ZERO; } fn advance(&mut self, delta: Duration) -> bool { - self.elapsed = (self.elapsed + delta).min(WORKSPACE_SLIDE_DURATION); - self.elapsed < WORKSPACE_SLIDE_DURATION + if self.held_progress.is_some() { + return true; + } + let mut moving = false; + for item in &mut self.items { + let mut x = AnimatedValue { + current: item.start.x, + target: item.target.x, + velocity: item.velocity.x, + }; + let mut y = AnimatedValue { + current: item.start.y, + target: item.target.y, + velocity: item.velocity.y, + }; + moving |= x.advance(delta, self.spring); + moving |= y.advance(delta, self.spring); + item.start = SlideOffset { + x: x.current, + y: y.current, + }; + item.velocity = SlideOffset { + x: x.velocity, + y: y.velocity, + }; + } + moving } fn contains(&self, workspace: WorkspaceId) -> bool { @@ -269,43 +356,16 @@ impl WorkspaceSlide { #[cfg(test)] fn offset(&self, workspace: WorkspaceId, width: f64, height: f64) -> (f64, f64) { - let progress = self.progress(); let position = self .items .iter() .find(|item| item.workspace == workspace) - .map(|item| item.start.between(item.target, progress)) + .map(|item| self.position(item, Duration::ZERO)) .unwrap_or_default(); (position.x * width, position.y * height) } } -#[derive(Clone, Copy, Debug, Default)] -struct ClosingAnimation { - progress: f64, - close_sent: bool, -} - -impl ClosingAnimation { - fn advance(&mut self, delta: Duration, animations_enabled: bool) -> bool { - if self.close_sent { - return false; - } - - self.progress = if animations_enabled { - (self.progress + delta.as_secs_f64() / CLOSE_ANIMATION_DURATION.as_secs_f64()).min(1.0) - } else { - 1.0 - }; - if self.progress < 1.0 { - return false; - } - - self.close_sent = true; - true - } -} - fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> Duration { if enabled { duration.div_f64(speed) @@ -373,7 +433,7 @@ pub struct Ferese { pub(crate) inactive_dim: crate::config::InactiveDimSettings, animations_enabled: bool, animation_speed: f64, - spring_config: SpringConfig, + pub(crate) spring_config: SpringConfig, viewport_spring_config: SpringConfig, pub(crate) output_profiles: Vec, pub(crate) autostart: Vec, @@ -784,6 +844,13 @@ impl Ferese { self.animation_speed = config.animation_speed; self.spring_config = config.spring_config; self.viewport_spring_config = config.viewport_spring_config; + for slide in self.workspace_slides.values_mut() { + slide.spring = SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.viewport_spring_config + }; + } self.output_profiles = config.output_profiles; self.autostart = config.autostart; if daemons_changed && let Some(runner) = self.daemons.clone() { @@ -940,12 +1007,6 @@ fn natural_floating_rect(bounds: Rect, size: ClientSize) -> Rect { ) } -fn smoothstep(progress: f64) -> f64 { - let progress = progress.clamp(0.0, 1.0); - - progress * progress * (3.0 - 2.0 * progress) -} - fn restored_scrolling_world_x(had_geometry: bool, visual_x: f64, viewport_x: f64, target_world_x: f64) -> f64 { if had_geometry { visual_x + viewport_x @@ -1044,20 +1105,60 @@ impl ClientData for ClientState { #[cfg(test)] mod tests { + #[test] + fn workspace_reversal_keeps_position_and_velocity() { + let from = WorkspaceId(1); + let to = WorkspaceId(2); + let mut slide = WorkspaceSlide::new(None, from, to, SwipeDirection::Up); + slide.advance(Duration::from_millis(40)); + let before: Vec<_> = slide + .items + .iter() + .map(|item| (item.workspace, item.start, item.velocity)) + .collect(); + let reversed = WorkspaceSlide::new(Some(slide), to, from, SwipeDirection::Down); + for (id, position, velocity) in before { + let item = reversed.items.iter().find(|item| item.workspace == id).unwrap(); + assert_eq!(item.start, position); + assert_eq!(item.velocity, velocity); + assert!(velocity.y < 0.0); + } + } + #[test] + fn viewport_limit_uses_resisted_position_and_release_velocity() { + let swipe = FocusSwipe { + workspace: WorkspaceId(1), + from: WindowId(1), + to: WindowId(1), + direction: Direction::Right, + gesture_direction: SwipeDirection::Left, + start: 20.0, + destination: 20.0, + progress: 2.0, + }; + assert!(swipe.position() > 20.0 && swipe.position() < 84.0); + assert!(swipe.release_velocity(0.0, 3.0) > 0.0); + let mut released = AnimatedValue { + current: swipe.position(), + target: swipe.start, + velocity: swipe.release_velocity(0.0, 3.0), + }; + let initial = released; + released.advance(Duration::from_micros(1), SpringConfig::default()); + assert!((released.current - initial.current - initial.velocity * 1e-6).abs() < 1e-6); + released.advance(Duration::from_secs(2), SpringConfig::default()); + assert_eq!(released.current, swipe.start); + } #[test] fn sampling_slide_progress_matches_advancing_without_mutating_the_slide() { let slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Left); - - for delta in [Duration::ZERO, Duration::from_millis(8), Duration::from_secs(1)] { + for delta in [Duration::ZERO, Duration::from_millis(16), Duration::from_millis(100)] { let mut advanced = slide.clone(); advanced.advance(delta); - assert_eq!(slide.progress_at(delta), advanced.progress()); - assert_eq!(slide.elapsed, WORKSPACE_SLIDE_FIRST_FRAME); + for (before, after) in slide.items.iter().zip(&advanced.items) { + assert_eq!(slide.position(before, delta), after.start); + } } - - let mut held = slide; - held.held_progress = Some(0.37); - assert_eq!(held.progress_at(Duration::from_secs(1)), 0.37); } #[test] @@ -1080,15 +1181,6 @@ mod tests { } } - #[test] - fn disabling_motion_finishes_an_in_progress_close_once() { - let mut animation = ClosingAnimation::default(); - assert!(!animation.advance(Duration::from_millis(35), true)); - assert!(animation.advance(Duration::ZERO, false)); - assert_eq!(animation.progress, 1.0); - assert!(!animation.advance(Duration::ZERO, false)); - } - #[test] fn workspace_drag_cancels_when_focus_or_target_ownership_changes() { let mut outputs = OutputWorkspaceMap::default(); @@ -1241,7 +1333,7 @@ mod tests { slide.release(committed); assert_eq!(slide.offset(from, 1000.0, 1000.0), (0.0, -400.0)); assert_eq!(slide.offset(to, 1000.0, 1000.0), (0.0, 600.0)); - assert!(!slide.advance(WORKSPACE_SLIDE_DURATION)); + assert!(!slide.advance(SLIDE_SETTLE_TEST_BUDGET)); let active = if committed { to } else { from }; assert_eq!(slide.offset(active, 1000.0, 1000.0), (0.0, 0.0)); } @@ -1253,14 +1345,14 @@ mod tests { let second = WorkspaceId(2); let third = WorkspaceId(3); let mut previous = WorkspaceSlide::new(None, first, second, SwipeDirection::Up); - previous.advance(WORKSPACE_SLIDE_DURATION / 3); + previous.advance(Duration::from_millis(40)); let mut slide = WorkspaceSlide::new(Some(previous), second, third, SwipeDirection::Up); slide.gesture = Some((second, third, SwipeDirection::Up)); slide.held_progress = Some(0.25); let position = slide.offset(second, 1000.0, 1000.0); slide.release(false); assert_eq!(slide.offset(second, 1000.0, 1000.0), position); - slide.advance(WORKSPACE_SLIDE_DURATION); + slide.advance(SLIDE_SETTLE_TEST_BUDGET); assert_eq!(slide.offset(second, 1000.0, 1000.0), (0.0, 0.0)); } @@ -1279,13 +1371,13 @@ mod tests { let mut slide = WorkspaceSlide::new(None, from, to, direction); assert!(slide.contains(from)); assert!(slide.contains(to)); - assert!(slide.advance(WORKSPACE_SLIDE_DURATION / 2)); + assert!(slide.advance(Duration::from_millis(50))); let outgoing = slide.offset(from, width, height); let incoming = slide.offset(to, width, height); assert!((incoming.0 - outgoing.0).abs() == outgoing_target.0.abs()); assert!((incoming.1 - outgoing.1).abs() == outgoing_target.1.abs()); - assert!(!slide.advance(WORKSPACE_SLIDE_DURATION)); + assert!(!slide.advance(SLIDE_SETTLE_TEST_BUDGET)); assert_eq!(slide.offset(from, width, height), outgoing_target); assert_eq!(slide.offset(to, width, height), (0.0, 0.0)); } @@ -1297,7 +1389,7 @@ mod tests { let second = WorkspaceId(2); let third = WorkspaceId(3); let mut slide = WorkspaceSlide::new(None, first, second, SwipeDirection::Up); - slide.advance(WORKSPACE_SLIDE_DURATION / 3); + slide.advance(Duration::from_millis(40)); let first_position = slide.offset(first, 1600.0, 900.0); let second_position = slide.offset(second, 1600.0, 900.0); @@ -1404,24 +1496,6 @@ mod tests { assert_eq!(transient, Rect::new(280.0, 210.0, 640.0, 480.0)); } - #[test] - fn close_easing_is_bounded_and_symmetric() { - assert_eq!(smoothstep(-1.0), 0.0); - assert_eq!(smoothstep(0.5), 0.5); - assert_eq!(smoothstep(2.0), 1.0); - } - - #[test] - fn close_animation_delays_the_protocol_close_until_it_finishes() { - let mut animation = ClosingAnimation::default(); - - assert!(!animation.advance(Duration::from_millis(70), true)); - assert_eq!(animation.progress, 0.5); - assert!(animation.advance(Duration::from_millis(70), true)); - assert!(animation.close_sent); - assert!(!animation.advance(Duration::from_secs(1), true)); - } - #[test] fn coupled_right_column_width_keeps_its_right_edge_stable() { let spring = SpringConfig { @@ -1430,6 +1504,7 @@ mod tests { damping: 2.0 * 320.0_f64.sqrt(), position_tolerance: 0.1, velocity_tolerance: 0.1, + ..SpringConfig::default() }; let mut width = AnimatedValue::new(980.0); let mut viewport = AnimatedValue::new(495.0); diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 1d95e3d..cb728ee 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -82,6 +82,7 @@ impl Ferese { if self.focus_swipe.is_some() || !self.paused_workspaces.is_empty() || !self.workspace_slides.is_empty() + || !self.render.closing.is_empty() || !self.dismissing_popups.is_empty() || self.render.snapshots().next().is_some() || self.overview.needs_tick() @@ -192,11 +193,22 @@ impl Ferese { for (&id, record) in self.windows.records_mut() { let selected = selected_window == Some(id); let target = if selected { 1.0 } else { 0.0 }; - let focus = record - .focus - .get_or_insert_with(|| crate::dimming::DimAnimation::new(target)); + let focus = record.focus.get_or_insert_with(|| AnimatedValue::new(target)); let previous = focus.current; - active_animation |= focus.advance_visual(target, delta, duration); + let changed = focus.target != target; + focus.set_target(target); + if self.animations_enabled { + active_animation |= focus.advance( + if changed { Duration::ZERO } else { delta }, + SpringConfig { + position_tolerance: 0.001, + velocity_tolerance: 0.001, + ..self.spring_config + }, + ); + } else { + focus.snap(); + } dim_changed |= previous != focus.current; } @@ -217,19 +229,40 @@ impl Ferese { dim_changed |= previous != dim.current; } - let mut ready_to_close = Vec::new(); - for (id, record) in self.windows.records_mut() { - let Some(animation) = &mut record.closing else { continue }; - - if animation.advance(delta, self.animations_enabled) { - ready_to_close.push(*id); - } else if !animation.close_sent { - active_animation = true; + for (_, record) in self.windows.records_mut() { + if let Some(opening) = &mut record.opening { + let active = if self.animations_enabled { + opening.advance( + delta, + SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.spring_config + }, + ) + } else { + opening.snap(); + false + }; + active_animation |= active; + if !active { + record.opening = None; + } } } - - for id in ready_to_close { - self.send_window_close(id); + let mut finished = Vec::new(); + self.render.closing.retain_mut(|window| { + let active = + self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config); + if !active { + finished.push(window.id); + } + active_animation |= active; + active + }); + for id in finished { + tracing::debug!(?id, "released close presentation"); + self.render.remove_window(id); } // Include transactions expiring on this dispatch: their waiting time @@ -325,8 +358,7 @@ impl Ferese { viewport.current = swipe.position(); viewport.velocity = 0.0; } else if self.animations_enabled { - active_animation |= - viewport.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing); + active_animation |= viewport.advance(delta, self.viewport_spring_config); } else { viewport.snap(); } @@ -379,8 +411,7 @@ impl Ferese { if zooming { sync_scrolling_coordinates(geometry, world_x, viewport, true); } else if self.animations_enabled { - active_animation |= - world_x.advance_with_policy(delta, self.spring_config, CrossingPolicy::NoCrossing); + active_animation |= world_x.advance(delta, self.spring_config); } else { world_x.snap(); } @@ -392,7 +423,7 @@ impl Ferese { if let Some((_, width)) = record.coupled_width.as_mut() { let width_active = if self.animations_enabled { - width.advance_with_policy(delta, self.viewport_spring_config, CrossingPolicy::NoCrossing) + width.advance(delta, self.viewport_spring_config) } else { width.snap(); false @@ -462,6 +493,9 @@ impl Ferese { self.send_shell_snapshots(); active_animation = true; } + if !self.overview.is_active() { + self.retarget_overview(); + } active_animation |= self .overview .advance(delta, self.spring_config, self.animations_enabled); @@ -525,10 +559,9 @@ impl Ferese { else { continue; }; - let progress = slide.progress_at(delta); for item in &slide.items { - let position = item.start.between(item.target, progress); + let position = slide.position(item, delta); offsets.insert( item.workspace, (position.x * f64::from(size.w), position.y * f64::from(size.h)), @@ -573,7 +606,7 @@ fn record_needs_tick( mapped: impl FnOnce() -> bool, ) -> bool { if record.resize.is_some() - || record.closing.as_ref().is_some_and(|close| !close.close_sent) + || record.opening.is_some() || record.focus.as_ref().is_none_or(|motion| motion.needs_update(focus)) { return true; @@ -610,7 +643,7 @@ mod tests { WindowRecord { geometry: Some(geometry), - focus: Some(DimAnimation::new(1.0)), + focus: Some(AnimatedValue::new(1.0)), dimming: Some(DimAnimation::new(0.0)), ..Default::default() } @@ -660,11 +693,11 @@ mod tests { } #[test] - fn close_requests_and_coupled_width_cleanup_still_get_their_final_tick() { + fn opening_and_coupled_width_cleanup_still_get_their_final_tick() { let mut record = settled(); - record.closing = Some(ClosingAnimation::default()); + record.opening = Some(AnimatedValue::new(0.0)); assert!(record_needs_tick(&record, 1.0, 0.0, || false)); - record.closing.as_mut().unwrap().close_sent = true; + record.opening = None; assert!(!record_needs_tick(&record, 1.0, 0.0, || true)); record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0))); assert!(record_needs_tick(&record, 1.0, 0.0, || true)); @@ -871,7 +904,7 @@ mod tests { // A settled transaction still has to flush its resume guard; otherwise // the next unrelated animation would inherit an old paused workspace. state.viewport_animations.get_mut(&workspace).unwrap().snap(); - state.windows.record_mut(id).unwrap().focus = Some(DimAnimation::new(0.0)); + state.windows.record_mut(id).unwrap().focus = Some(AnimatedValue::new(0.0)); state.windows.set_transaction( id, crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)), diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs index 3766d9c..c5a42a9 100644 --- a/compositor/src/state/focus.rs +++ b/compositor/src/state/focus.rs @@ -10,17 +10,13 @@ impl Ferese { (window.toplevel().is_some() && self.window_content_ready(window) && self.windows.geometry(id).is_some() - && self.output_workspaces.focused_output().is_some() - && self.windows.record(*id).is_none_or(|record| record.closing.is_none())) + && self.output_workspaces.focused_output().is_some()) .then_some(*id) }) .collect() } pub(crate) fn focus_preview_output(&self, id: WindowId) -> Option { - if self.windows.record(id).is_some_and(|record| record.closing.is_some()) { - return None; - } self.workspaces .workspace_for_window(id) .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)) diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index 1c6c887..3099149 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -202,12 +202,16 @@ impl Ferese { } pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { - if !self.overview.is_presenting() { + let id = self.windows.ids().get(window)?; + if !self.overview.is_presenting() && self.windows.record(*id)?.opening.is_none() { return Some((1.0, 1.0)); } - let id = self.windows.ids().get(window)?; let geometry = self.windows.geometry(id)?; - let source = geometry.client.committed_size?; + let source = if self.overview.is_presenting() { + geometry.client.committed_size? + } else { + ClientSize::from_rect(geometry.visual.current) + }; let presented = self.presented_window_rect(*id)?; if source.width <= 0 || source.height <= 0 { diff --git a/compositor/src/state/lifecycle.rs b/compositor/src/state/lifecycle.rs new file mode 100644 index 0000000..eb73089 --- /dev/null +++ b/compositor/src/state/lifecycle.rs @@ -0,0 +1,174 @@ +use super::*; +use ferese_animation::AnimatedRect; +use smithay::backend::renderer::Texture; +use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes}; + +impl Ferese { + pub(crate) fn capture_close_before_commit(&mut self, surface: &WlSurface) { + // At this point cached attributes contain the committed removal, but + // on_commit_buffer_handler has not reset the previous renderer state. + let removed = with_states(surface, |states| { + matches!( + states.cached_state.get::().current().buffer, + Some(BufferAssignment::Removed) + ) + }); + if removed + && let Some(window) = self + .windows + .id_for_surface(surface) + .and_then(|id| self.windows.window(id)) + .cloned() + { + self.retain_closed_window(&window); + } + } + + pub(super) fn retain_closed_window(&mut self, window: &Window) { + if !self.animations_enabled || self.session_lock.active() || self.render.closing.len() >= 64 { + return; + } + let Some(&id) = self.windows.ids().get(window) else { + return; + }; + if self.render.closing.iter().any(|window| window.id == id) || !self.window_content_ready(window) { + return; + } + let Some(output) = self + .space + .outputs() + .find(|output| self.window_belongs_to_output(id, output)) + .cloned() + else { + return; + }; + let Some(output_id) = self.output_id(&output) else { + return; + }; + let Some(rect) = self.presented_window_rect(id) else { + return; + }; + let record = self.windows.record(id).unwrap(); + let geometry = record.geometry.unwrap(); + let decorations = geometry.decorations.clamp(0.0, 1.0); + let focus = record.focus.as_ref().map_or(0.0, |focus| focus.current.clamp(0.0, 1.0)); + let dim = record.dimming.as_ref().map_or(0.0, |dim| dim.current); + let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0)); + opacity.set_target(0.0); + let mut bounds = AnimatedRect::new(rect); + bounds.velocity = self.overview.presented_velocity(id).unwrap_or(geometry.visual.velocity); + if !self.overview.is_presenting() + && let Some(slide) = self.workspace_slides.get(&output_id) + && let Some(workspace) = self.workspaces.workspace_for_window(id) + && let Some(item) = slide.items.iter().find(|item| item.workspace == workspace) + && let Some(area) = self.space.output_geometry(&output) + { + bounds.velocity.x += item.velocity.x * f64::from(area.size.w); + bounds.velocity.y += item.velocity.y * f64::from(area.size.h); + } + if let Some(opening) = record.opening { + bounds.velocity = crate::presentation::scaled_visual_velocity( + self.overview.presented_rect(id, geometry.visual.current), + bounds.velocity, + 0.97 + 0.03 * opening.current, + 0.03 * opening.velocity, + ); + } + bounds.set_target(crate::presentation::scaled_visual_rect(rect, 0.98)); + let mut source_geometry = window.geometry(); + if !self.overview.is_presenting() { + let size = ClientSize::from_rect(geometry.visual.current); + source_geometry.size = (size.width, size.height).into(); + } + let order: Vec<_> = if self.overview.is_active() { + self.windows + .overview_windows() + .filter_map(|window| self.windows.ids().get(window).copied()) + .collect() + } else { + self.space + .elements() + .rev() + .filter_map(|window| self.windows.ids().get(window).copied()) + .collect() + }; + let below = order + .iter() + .position(|candidate| *candidate == id) + .and_then(|index| order.get(index + 1)) + .copied(); + let used = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::() + + self + .render + .closing + .iter() + .map(|window| window.snapshot.bytes()) + .sum::(); + let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); + let scale = output.current_scale().fractional_scale(); + let result = if let Some(backend) = &self.nested_backend { + match backend.try_borrow_mut() { + Ok(mut backend) => crate::render::capture_resize_snapshot( + backend.renderer(), + window, + source_geometry, + scale, + remaining, + ), + Err(_) => return, + } + } else if let Some(backend) = &mut self.direct_backend { + backend.capture_resize_snapshot(window, source_geometry, &output, remaining) + } else { + return; + }; + match result { + Ok(Some(snapshot)) => { + tracing::debug!(?id, bytes = snapshot.bytes(), "retained close presentation"); + let size = snapshot.texture.size(); + let material = window.toplevel().and_then(|toplevel| { + let surface = toplevel.wl_surface(); + let (role, generation) = crate::effects::surface_role(surface)?; + (role == crate::effects::SemanticRole::Modal).then(|| { + ( + surface.clone(), + role, + generation, + crate::effects::surface_opacity(surface), + ) + }) + }); + let radius = if material.is_some() { + self.theme_settings.material_radius + } else { + self.theme_settings.window_radius + } * decorations; + let client = window.geometry().size; + let fill = (!self.overview.is_presenting() + && material.is_none() + && (client.w < source_geometry.size.w || client.h < source_geometry.size.h)) + .then_some(self.theme_settings.surface_base_color.0); + self.render.closing.push(crate::render::ClosedWindow { + id, + output: output_id, + below, + bounds, + opacity, + snapshot, + source: Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into()), + radius, + shape: crate::render::window_corner_shape(window), + focus, + decorations, + dim, + fill, + material, + }); + self.last_animation_tick = Instant::now(); + self.defer_output_redraw(output); + } + Ok(None) => {} + Err(error) => tracing::debug!(%error, ?id, "close snapshot unavailable"), + } + } +} diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 2056d7b..087b5ff 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -34,9 +34,10 @@ impl Ferese { if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) { return; } - let Ok(Some(to)) = workspace.layout.directional_neighbor(from, direction, bounds) else { + let Ok(neighbor) = workspace.layout.directional_neighbor(from, direction, bounds) else { return; }; + let to = neighbor.unwrap_or(from); let workspace_id = workspace.id; let start = self .viewport_animations @@ -45,7 +46,8 @@ impl Ferese { .or_else(|| workspace.layout.viewport_x()) .unwrap_or(0.0); let mut candidate = workspace.layout.clone(); - if let WorkspaceLayout::Scrolling(layout) = &mut candidate + if to != from + && let WorkspaceLayout::Scrolling(layout) = &mut candidate && layout.slide_focus_from(from, to).is_err() { return; @@ -63,7 +65,11 @@ impl Ferese { direction, gesture_direction, start, - destination: candidate.viewport_x().unwrap_or(start), + destination: if to == from { + start + } else { + candidate.viewport_x().unwrap_or(start) + }, progress, }); self.swipe.mark_preview_started(); @@ -91,7 +97,9 @@ impl Ferese { // Release from the last input position even if no frame rendered that update. if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { viewport.current = swipe.position(); - viewport.velocity = 0.0; + viewport.velocity = swipe + .release_velocity(self.swipe.release_velocity, self.swipe.unbounded_release_velocity) + / self.animation_speed; } let neighbor = self.output_bounds().and_then(|bounds| { self.workspaces @@ -460,6 +468,11 @@ impl Ferese { }; let previous = self.workspace_slides.remove(&output); let mut slide = WorkspaceSlide::new(previous, from, to, direction); + slide.spring = SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.viewport_spring_config + }; slide.held_progress = Some(progress); slide.gesture = Some((from, to, direction)); self.workspace_slides.insert(output, slide); @@ -481,7 +494,7 @@ impl Ferese { && self.activate_managed_workspace_from_swipe(to, expected); if !committed { if let Some(slide) = self.workspace_slides.get_mut(&output) { - slide.release(false); + slide.release_with_velocity(false, self.swipe.release_velocity / self.animation_speed); } self.last_animation_tick = Instant::now(); self.relayout(); @@ -497,26 +510,55 @@ impl Ferese { workspace: WorkspaceId, slide_direction: Option, ) { + if owner != requested_output { + return; + } let mut previous_slide = self.workspace_slides.remove(&owner); + if previous == Some(workspace) { + if let Some(slide) = previous_slide { + self.workspace_slides.insert(owner, slide); + } + return; + } if owner == requested_output && let Some(slide) = previous_slide.as_mut() && slide .gesture .is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction) { - slide.release(true); + slide.release_with_velocity(true, self.swipe.release_velocity / self.animation_speed); self.last_animation_tick = Instant::now(); self.workspace_slides.insert(owner, previous_slide.unwrap()); return; } + let slide_direction = slide_direction.or_else(|| { + let from = previous?; + let ids = self + .output_workspaces + .ordered_workspace_ids(&self.workspaces, owner) + .collect::>(); + let from = ids.iter().position(|id| *id == from)?; + let to = ids.iter().position(|id| *id == workspace)?; + Some(if to > from { + SwipeDirection::Up + } else { + SwipeDirection::Down + }) + }); if owner == requested_output && let (Some(from), Some(direction)) = (previous, slide_direction) && from != workspace && self.animations_enabled + && !self.overview.is_presenting() { self.last_animation_tick = Instant::now(); - self.workspace_slides - .insert(owner, WorkspaceSlide::new(previous_slide, from, workspace, direction)); + let mut slide = WorkspaceSlide::new(previous_slide, from, workspace, direction); + slide.spring = SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.viewport_spring_config + }; + self.workspace_slides.insert(owner, slide); } } diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index d1ac716..1f04cb1 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -7,13 +7,15 @@ pub(crate) struct FrameScene { pub overview: crate::overview::OverviewMotion, // Plane selection and scheduling reuse the activity sampled with this scene. pub animating: bool, + pub delta: Duration, } pub(crate) struct WindowFrame { pub geometry: WindowGeometry, pub rect: Rect, - pub close_scale: f64, - pub close_alpha: f32, + pub scale_content: bool, + pub native_size: Option, + pub alpha: f32, pub focus: f64, pub dim: f64, } @@ -68,7 +70,12 @@ impl Ferese { } pub(crate) fn output_has_animations(&self, output: &Output) -> bool { - if self.overview.is_animating(self.spring_config) + if self + .render + .closing + .iter() + .any(|window| Some(window.output) == self.output_id(output)) + || self.overview.is_animating(self.spring_config) || self .dismissing_popups .iter() @@ -80,7 +87,7 @@ impl Ferese { if self .output_id(output) .and_then(|id| self.workspace_slides.get(&id)) - .is_some_and(|slide| slide.held_progress.is_none() && slide.elapsed < WORKSPACE_SLIDE_DURATION) + .is_some_and(|slide| slide.held_progress.is_none() && slide.moving()) { return true; } @@ -95,8 +102,8 @@ impl Ferese { } if record.resize.is_some() + || record.opening.is_some() || self.render.snapshot(&id).is_some() - || record.closing.as_ref().is_some_and(|close| !close.close_sent) || record.focus.as_ref().is_some_and(|focus| focus.is_animating()) || record.dimming.as_ref().is_some_and(|dim| dim.is_animating()) { @@ -117,15 +124,10 @@ impl Ferese { .focus_swipe .as_ref() .is_some_and(|swipe| swipe.workspace == workspace); - !held - && viewport.advance_with_policy( - Duration::ZERO, - self.viewport_spring_config, - CrossingPolicy::NoCrossing, - ) + !held && viewport.advance(Duration::ZERO, self.viewport_spring_config) }) || record.coupled_width.as_ref().is_some_and(|(_, width)| { let mut width = *width; - width.advance_with_policy(Duration::ZERO, self.viewport_spring_config, CrossingPolicy::NoCrossing) + width.advance(Duration::ZERO, self.viewport_spring_config) }) }) } @@ -187,12 +189,19 @@ impl Ferese { rect.y += y; } - let mut close = record.closing.as_ref().copied().unwrap_or_default(); - if record.closing.is_some() && !delta.is_zero() { - close.advance(delta, true); + let mut opening = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0)); + if !self.animations_enabled { + opening.snap(); + } else if !delta.is_zero() { + opening.advance( + delta, + SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.spring_config + }, + ); } - - let eased = smoothstep(close.progress); let focused = if self.overview.is_active() { self.overview_selected(id) } else { @@ -211,14 +220,31 @@ impl Ferese { motion.current }; + let mut focus = record + .focus + .unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 })); + if !delta.is_zero() { + focus.advance( + delta, + SpringConfig { + position_tolerance: 0.001, + velocity_tolerance: 0.001, + ..self.spring_config + }, + ); + } + let presence_scale = 0.97 + 0.03 * opening.current; + let native_size = (!overview.is_presenting() && presence_scale != 1.0).then(|| ClientSize::from_rect(rect)); + let rect = crate::presentation::scaled_visual_rect(rect, presence_scale); windows.insert( id, WindowFrame { geometry, rect, - close_scale: 1.0 - eased * 0.02, - close_alpha: (1.0 - eased) as f32, - focus: sample_dim(record.focus.as_ref(), if focused { 1.0 } else { 0.0 }), + scale_content: overview.is_presenting(), + native_size, + alpha: opening.current.clamp(0.0, 1.0) as f32, + focus: focus.current.clamp(0.0, 1.0), dim: sample_dim(record.dimming.as_ref(), 0.0), }, ); @@ -228,6 +254,7 @@ impl Ferese { windows, overview, animating, + delta, } } } @@ -252,9 +279,9 @@ fn predict_geometry( if let Some((mut world, mut viewport, held)) = world && !zooming { - world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); + world.advance(delta, spring); if !held { - viewport.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing); + viewport.advance(delta, viewport_spring); } predicted.visual.current.x = world.current - viewport.current; @@ -262,7 +289,7 @@ fn predict_geometry( } if let Some(mut width) = width { - width.advance_with_policy(delta, viewport_spring, CrossingPolicy::NoCrossing); + width.advance(delta, viewport_spring); predicted.visual.current.width = width.current; predicted.visual.velocity.width = width.velocity; } @@ -454,7 +481,7 @@ mod tests { state.focused_window = Some(id); state .windows - .update(id, |record| record.focus = Some(crate::dimming::DimAnimation::new(1.0))); + .update(id, |record| record.focus = Some(AnimatedValue::new(1.0))); state.focused_window = Some(other_id); assert!(state.output_has_pending_visual_changes(&output)); state.animations_enabled = false; diff --git a/compositor/src/state/window_registry.rs b/compositor/src/state/window_registry.rs index eb43f4c..4a65316 100644 --- a/compositor/src/state/window_registry.rs +++ b/compositor/src/state/window_registry.rs @@ -21,9 +21,10 @@ pub(crate) struct WindowRecord { pub title: String, pub metadata_revision: u64, pub resize: Option, - pub focus: Option, + pub focus: Option, pub dimming: Option, - pub(super) closing: Option, + pub opening: Option, + pub mapped_once: bool, pub world_x: Option<(WorkspaceId, AnimatedValue)>, pub coupled_width: Option<(WorkspaceId, AnimatedValue)>, pub maximized: bool, @@ -302,8 +303,8 @@ mod tests { record.resize_anchor = Some((true, false, Rect::new(0., 0., 800., 600.))); record.column_width_pending = true; record.rules_applied = true; - record.focus = Some(crate::dimming::DimAnimation::new(1.0)); - record.closing = Some(super::super::ClosingAnimation::default()); + record.focus = Some(AnimatedValue::new(1.0)); + record.opening = Some(AnimatedValue::new(0.0)); record.world_x = Some((WorkspaceId(1), AnimatedValue::new(50.0))); record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(800.0))); }); diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 0712370..17148ca 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -417,6 +417,7 @@ impl Ferese { } pub fn remove_tiled_window(&mut self, window: &Window) { + self.retain_closed_window(window); // Uncommitted toplevels have not entered a workspace yet. self.space.unmap_elem(window); let Some(id) = self.windows.remove(window) else { @@ -493,7 +494,13 @@ impl Ferese { return; } - let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum(); + let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::() + + self + .render + .closing + .iter() + .map(|window| window.snapshot.bytes()) + .sum::(); let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); let result = if let Some(backend) = &self.nested_backend { // Commit dispatch does not run inside the Winit event callback; @@ -557,6 +564,20 @@ impl Ferese { return; }; + if let Some(record) = self.windows.record_mut(id) + && !record.mapped_once + { + record.mapped_once = true; + if self.animations_enabled { + record.opening = Some(AnimatedValue { + current: 0.0, + target: 1.0, + velocity: 0.0, + }); + self.last_animation_tick = Instant::now(); + } + } + let fullscreen = self .workspaces .workspace_for_window(id) @@ -648,35 +669,16 @@ impl Ferese { } pub fn close_focused_window(&mut self) { - let Some(focused) = self.focused_window else { - return; - }; - - if !self.animations_enabled { + if let Some(focused) = self.focused_window { self.send_window_close(focused); - return; } - - self.windows.update(focused, |record| { - record.closing.get_or_insert_default(); - }); - crate::backends::direct::render_window(self, focused); } pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { if self.windows.window(id).is_none() { return false; } - - if self.animations_enabled { - self.windows.update(id, |record| { - record.closing.get_or_insert_default(); - }); - crate::backends::direct::render_window(self, id); - } else { - self.send_window_close(id); - } - + self.send_window_close(id); true } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 7edb9b5..9b34fcb 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -95,6 +95,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let mut backend = backend.borrow_mut(); match event { WinitEvent::Resized { size, scale_factor } => { + tracing::debug!(target: "ferese::nested_input", ?size, scale_factor, "host resized nested output"); let scale = normalized_scale(scale_factor); let rate = backend .window() @@ -115,7 +116,13 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() } backend.window().request_redraw(); } - WinitEvent::Input(event) => state.process_input_event(event), + WinitEvent::Input(event) => { + let kind = std::mem::discriminant(&event); + state.process_input_event(event); + tracing::debug!(target: "ferese::nested_input", ?kind, + pointer = ?state.seat.get_pointer().map(|pointer| pointer.current_location()), + "host input in nested output"); + } WinitEvent::Redraw => { state.advance_animations(Instant::now()); let age = backend.buffer_age().unwrap_or(0); diff --git a/crates/ferese-animation/Cargo.toml b/crates/ferese-animation/Cargo.toml index 6c05919..7454542 100644 --- a/crates/ferese-animation/Cargo.toml +++ b/crates/ferese-animation/Cargo.toml @@ -7,3 +7,4 @@ rust-version.workspace = true [dependencies] ferese-layout = { path = "../ferese-layout" } +serde.workspace = true diff --git a/crates/ferese-animation/src/gesture.rs b/crates/ferese-animation/src/gesture.rs new file mode 100644 index 0000000..f388d28 --- /dev/null +++ b/crates/ferese-animation/src/gesture.rs @@ -0,0 +1,135 @@ +//! Input estimation and destination selection; all post-release motion uses springs. +use std::{collections::VecDeque, time::Duration}; + +#[derive(Default, Debug)] +pub struct VelocityTracker { + samples: VecDeque<(Duration, f64)>, +} + +impl VelocityTracker { + pub fn push(&mut self, time: Duration, position: f64) { + if !position.is_finite() { + return; + } + if let Some(&(last, _)) = self.samples.back() { + if time < last { + return; + } + if time == last { + self.samples.pop_back(); + } + } + self.samples.push_back((time, position)); + while self + .samples + .front() + .is_some_and(|(t, _)| time.saturating_sub(*t) > Duration::from_millis(100)) + || self.samples.len() > 32 + { + self.samples.pop_front(); + } + } + + pub fn velocity(&self, now: Duration) -> f64 { + let Some(&(last, origin)) = self.samples.back() else { + return 0.0; + }; + if now.saturating_sub(last) > Duration::from_millis(40) || self.samples.len() < 2 { + return 0.0; + } + // Fit positions relative to the newest sample, with time normalized to + // the history horizon to avoid ill-conditioned powers of tiny seconds. + let degree = if self.samples.len() >= 3 { 2 } else { 1 }; + let mut matrix = [[0.0; 4]; 3]; + for &(time, position) in &self.samples { + let t = -last.saturating_sub(time).as_secs_f64() / 0.1; + let powers = [1.0, t, t * t, t * t * t, t * t * t * t]; + for row in 0..=degree { + for col in 0..=degree { + matrix[row][col] += powers[row + col]; + } + matrix[row][degree + 1] += powers[row] * (position - origin); + } + } + for col in 0..=degree { + let pivot = (col..=degree) + .max_by(|&a, &b| matrix[a][col].abs().total_cmp(&matrix[b][col].abs())) + .unwrap(); + matrix.swap(col, pivot); + let divisor = matrix[col][col]; + if divisor.abs() < 1e-12 { + let (first, position) = self.samples[0]; + let dt = last.saturating_sub(first).as_secs_f64(); + return if dt > 0.0 { (origin - position) / dt } else { 0.0 }; + } + for entry in col..=degree + 1 { + matrix[col][entry] /= divisor; + } + for row in 0..=degree { + if row == col { + continue; + } + let factor = matrix[row][col]; + for entry in col..=degree + 1 { + matrix[row][entry] -= factor * matrix[col][entry]; + } + } + } + matrix[1][degree + 1] / 0.1 + } +} + +pub fn project(position: f64, velocity: f64, rate: f64) -> f64 { + if !velocity.is_finite() || !(0.0..1.0).contains(&rate) || rate == 0.0 { + return position; + } + position - velocity / (1000.0 * rate.ln()) +} + +/// Nonlinear resistance, bounded by `extent`; the derivative transfers the +/// velocity of the displayed (resisted) position into the release spring. +pub fn rubber_band(distance: f64, extent: f64, resistance: f64) -> (f64, f64) { + if !distance.is_finite() || !extent.is_finite() || extent <= 0.0 || !resistance.is_finite() || resistance <= 0.0 { + return (0.0, 0.0); + } + let denominator = 1.0 + resistance * distance.abs() / extent; + ( + distance.signum() * extent * (1.0 - denominator.recip()), + resistance / denominator.powi(2), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rubber_band_is_bounded_symmetric_and_has_matching_velocity() { + for x in [0.0, 1.0, 100.0, 10000.0] { + let (position, derivative) = rubber_band(x, 64.0, 0.55); + assert!((0.0..64.0).contains(&position)); + assert_eq!(rubber_band(-x, 64.0, 0.55).0, -position); + let measured = (rubber_band(x + 0.0001, 64.0, 0.55).0 - rubber_band(x - 0.0001, 64.0, 0.55).0) / 0.0002; + assert!((measured - derivative).abs() < 1e-6); + } + } + #[test] + fn quadratic_velocity_and_pause() { + let mut tracker = VelocityTracker::default(); + for ms in [0, 7, 19, 40, 60, 90] { + let t = ms as f64 / 1000.0; + tracker.push(Duration::from_millis(ms), 100.0 * t + 200.0 * t * t); + } + assert!((tracker.velocity(Duration::from_millis(90)) - 136.0).abs() < 1e-8); + assert_eq!(tracker.velocity(Duration::from_millis(140)), 0.0); + assert!((project(0.0, 1.0, 0.997) - 0.332833082957).abs() < 1e-10); + } + #[test] + fn duplicate_and_out_of_order_samples() { + let mut tracker = VelocityTracker::default(); + tracker.push(Duration::ZERO, 0.0); + tracker.push(Duration::from_millis(10), 1.0); + tracker.push(Duration::from_millis(10), 2.0); + tracker.push(Duration::from_millis(5), 99.0); + assert!((tracker.velocity(Duration::from_millis(10)) - 200.0).abs() < 1e-10); + } +} diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index 0b45c8d..288460f 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -2,7 +2,10 @@ use std::time::Duration; use ferese_layout::Rect; +mod settings; mod spring; +pub use settings::{MotionSettings, SpringSettings}; +pub mod gesture; /// Crossing behavior belongs to the property, not the physical solver. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -20,6 +23,7 @@ pub struct SpringConfig { pub damping: f64, pub position_tolerance: f64, pub velocity_tolerance: f64, + pub crossing: CrossingPolicy, } impl Default for SpringConfig { @@ -30,6 +34,7 @@ impl Default for SpringConfig { damping: 53.0, position_tolerance: 0.1, velocity_tolerance: 0.1, + crossing: CrossingPolicy::NoCrossing, } } } @@ -44,6 +49,7 @@ impl SpringConfig { damping: non_negative_or(self.damping, defaults.damping), position_tolerance: positive_or(self.position_tolerance, defaults.position_tolerance), velocity_tolerance: positive_or(self.velocity_tolerance, defaults.velocity_tolerance), + crossing: self.crossing, } } } @@ -71,6 +77,14 @@ pub struct AnimatedValue { } impl AnimatedValue { + pub fn needs_update(&self, target: f64) -> bool { + self.target != target || self.is_animating() + } + + pub fn is_animating(&self) -> bool { + self.current != self.target || self.velocity != 0.0 + } + pub fn new(value: f64) -> Self { let value = finite_or_zero(value); @@ -86,13 +100,7 @@ impl AnimatedValue { } pub fn retarget_preserving_motion(&mut self, target: f64) { - let target = finite_or_zero(target); - let direction = (target - self.current).signum(); - - if direction != 0.0 && self.velocity.signum() != direction { - self.velocity *= 0.35; - } - self.target = target; + self.set_target(target); } pub fn snap(&mut self) { @@ -100,9 +108,9 @@ impl AnimatedValue { self.velocity = 0.0; } - /// Compatibility entry point; existing presentation properties do not cross. + /// Advance with the spring's configured crossing policy. pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { - self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) + self.advance_with_policy(delta, config, config.crossing) } pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { @@ -156,7 +164,7 @@ impl AnimatedRect { } pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { - self.advance_with_policy(delta, config, CrossingPolicy::NoCrossing) + self.advance_with_policy(delta, config, config.crossing) } pub fn advance_with_policy(&mut self, delta: Duration, config: SpringConfig, policy: CrossingPolicy) -> bool { @@ -304,9 +312,7 @@ pub struct WindowGeometry { /// Maximized/fullscreen geometry and decorations share a clock, including on reversal. #[derive(Clone, Copy, Debug, PartialEq)] struct ZoomTransition { - from: Rect, - decorations_from: f64, - progress: AnimatedValue, + decorations: AnimatedValue, } impl WindowGeometry { @@ -359,28 +365,12 @@ impl WindowGeometry { let rect = normalized_rect(rect); let before = (self.visual.current, self.decorations); if self.mode != mode || (self.zoom.is_some() && self.logical != rect) { - let mut progress = AnimatedValue::new(0.0); - progress.set_target(1.0); - let from = self.visual.current; - let distance = [ - rect.x - from.x, - rect.y - from.y, - rect.width - from.width, - rect.height - from.height, - ]; - let velocity = self.visual.velocity; - let speed = [velocity.x, velocity.y, velocity.width, velocity.height]; - let squared_length = distance.iter().map(|value| value * value).sum::(); - if squared_length > 0.001 { - let projected = distance.iter().zip(speed).map(|(d, v)| d * v).sum::() / squared_length; - // Keep forward momentum; soften a reversal without jumping position. - progress.velocity = if projected < 0.0 { projected * 0.35 } else { projected }; - } - self.zoom = Some(ZoomTransition { - from: self.visual.current, - decorations_from: self.decorations, - progress, - }); + let mut decorations = self + .zoom + .map(|zoom| zoom.decorations) + .unwrap_or_else(|| AnimatedValue::new(self.decorations)); + decorations.set_target(if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 }); + self.zoom = Some(ZoomTransition { decorations }); } self.mode = mode; self.logical = rect; @@ -440,55 +430,23 @@ impl WindowGeometry { return self.zoom.is_some() || !self.visual.is_settled(config); } + // Retarget the existing rectangle directly: projecting four independent + // velocities onto a new scalar progress loses motion on interruption. + let geometry_active = self.visual.advance(delta, config); if let Some(zoom) = &mut self.zoom { - let target = self.visual.target; - // Spring tolerances describe pixels and pixels/second, whereas this - // spring advances unit progress. Without conversion it snaps several - // pixels early on large zooms, visibly shifting the trailing edge. - let travel = rect_distance(zoom.from, target) - .max((zoom.from.x + zoom.from.width - target.x - target.width).abs()) - .max((zoom.from.y + zoom.from.height - target.y - target.height).abs()) - .max(1.0); - let config = config.normalized(); - let progress_config = SpringConfig { - position_tolerance: config.position_tolerance / travel, - velocity_tolerance: config.velocity_tolerance / travel, + let decoration_config = SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, ..config }; - let active = zoom - .progress - .advance_with_policy(delta, progress_config, CrossingPolicy::NoCrossing); - let p = zoom.progress.current; - let lerp = |a: f64, b: f64| a + (b - a) * p; - self.visual.current = Rect::new( - lerp(zoom.from.x, target.x), - lerp(zoom.from.y, target.y), - lerp(zoom.from.width, target.width), - lerp(zoom.from.height, target.height), - ); - self.visual.velocity = RectVelocity { - x: (target.x - zoom.from.x) * zoom.progress.velocity, - y: (target.y - zoom.from.y) * zoom.progress.velocity, - width: (target.width - zoom.from.width) * zoom.progress.velocity, - height: (target.height - zoom.from.height) * zoom.progress.velocity, - }; - self.decorations = lerp( - zoom.decorations_from, - if self.mode == PresentationMode::Fullscreen { - 0.0 - } else { - 1.0 - }, - ); - if !active { + let decorations_active = zoom.decorations.advance(delta, decoration_config); + self.decorations = zoom.decorations.current.clamp(0.0, 1.0); + if !geometry_active && !decorations_active { self.zoom = None; - self.visual.snap(); } - return active; + return geometry_active || decorations_active; } - - self.visual - .advance_with_policy(delta, config, CrossingPolicy::NoCrossing) + geometry_active } pub fn inverse_visual_point(&self, x: f64, y: f64) -> Option<(f64, f64)> { @@ -931,7 +889,7 @@ mod tests { } #[test] - fn scalar_spring_softens_opposing_velocity_on_reversal() { + fn scalar_spring_preserves_opposing_velocity_on_reversal() { let mut value = AnimatedValue::new(0.0); value.set_target(1_000.0); value.advance(Duration::from_millis(40), SpringConfig::default()); @@ -941,7 +899,7 @@ mod tests { value.retarget_preserving_motion(-500.0); assert!(value.velocity > 0.0); - assert_eq!(value.velocity, velocity * 0.35); + assert_eq!(value.velocity, velocity); assert_eq!(value.current, current); } diff --git a/crates/ferese-animation/src/settings.rs b/crates/ferese-animation/src/settings.rs new file mode 100644 index 0000000..8982da3 --- /dev/null +++ b/crates/ferese-animation/src/settings.rs @@ -0,0 +1,298 @@ +use crate::{CrossingPolicy, SpringConfig}; + +/// One configuration contract for compositor and out-of-process shell motion. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] +#[serde(default)] +pub struct MotionSettings { + pub enabled: bool, + pub reduced_motion: bool, + pub speed: f64, + pub spring: SpringSettings, + pub viewport_spring: SpringSettings, +} + +impl Default for MotionSettings { + fn default() -> Self { + Self { + enabled: true, + reduced_motion: false, + speed: 1.0, + spring: SpringSettings::default(), + viewport_spring: SpringSettings::default(), + } + } +} + +impl MotionSettings { + pub fn motion_enabled(self) -> bool { + self.enabled && !self.reduced_motion + } + + pub fn validate(self) -> Result<(), String> { + if !self.speed.is_finite() || self.speed <= 0.0 { + return Err("animation speed must be finite and positive".into()); + } + self.spring.resolve(SpringConfig::default())?; + self.viewport_config()?; + Ok(()) + } + + pub fn viewport_config(self) -> Result { + let mut settings = self.viewport_spring; + if settings.duration_ms.is_none() && settings.damping.is_none() && settings.damping_ratio.is_none() { + settings.damping_ratio = Some(1.0); + } + settings.resolve(SpringConfig { + stiffness: 320.0, + damping: 2.0 * 320.0_f64.sqrt(), + ..SpringConfig::default() + }) + } + + /// Only small opacity changes should use a timed duration. + pub fn duration(self, milliseconds: f64) -> std::time::Duration { + if !self.motion_enabled() { + return std::time::Duration::ZERO; + } + std::time::Duration::from_secs_f64(milliseconds / 1000.0 / self.speed) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)] +#[serde(default)] +pub struct SpringSettings { + pub mass: Option, + pub stiffness: Option, + pub damping: Option, + pub damping_ratio: Option, + pub duration_ms: Option, + pub bounce: Option, + pub overshoot: bool, +} + +impl SpringSettings { + pub fn resolve(self, base: SpringConfig) -> Result { + let positive = |value: f64| value.is_finite() && value > 0.0; + let mut config = SpringConfig { + mass: self.mass.unwrap_or(base.mass), + stiffness: self.stiffness.unwrap_or(base.stiffness), + damping: self.damping.unwrap_or(base.damping), + crossing: if self.overshoot { + CrossingPolicy::AllowOvershoot + } else { + CrossingPolicy::NoCrossing + }, + ..base + }; + if !positive(config.mass) || !positive(config.stiffness) || !config.damping.is_finite() || config.damping < 0.0 + { + return Err("spring mass/stiffness must be positive and damping nonnegative".into()); + } + if let Some(duration) = self.duration_ms { + if self.stiffness.is_some() || self.damping.is_some() || self.damping_ratio.is_some() { + return Err("spring duration-ms cannot be combined with stiffness/damping/damping-ratio".into()); + } + let bounce = self.bounce.unwrap_or(0.0); + if !positive(duration) + || !bounce.is_finite() + || (bounce <= -1.0 || bounce >= 1.0) + || (bounce > 0.0 && !self.overshoot) + { + return Err("spring duration-ms must be positive; bounce must be between -1 and 1 (exclusive), and positive bounce requires overshoot".into()); + } + let ratio = if bounce >= 0.0 { + 1.0 - bounce + } else { + 1.0 / (1.0 + bounce) + }; + let omega = std::f64::consts::TAU / (duration / 1000.0); + config.stiffness = config.mass * omega * omega; + config.damping = 2.0 * config.mass * omega * ratio; + } else { + if self.bounce.is_some() { + return Err("spring bounce requires duration-ms".into()); + } + if let Some(ratio) = self.damping_ratio { + if self.damping.is_some() || !ratio.is_finite() || ratio < 0.0 { + return Err("spring damping-ratio must be nonnegative and cannot be combined with damping".into()); + } + config.damping = 2.0 * ratio * (config.mass * config.stiffness).sqrt(); + } + } + if !positive(config.stiffness) || !config.damping.is_finite() { + return Err("spring coefficients overflow".into()); + } + // Presentation lifetimes can depend on settlement. An undamped spring + // only settles when the first-crossing clamp is enabled. + if config.damping == 0.0 && config.crossing == CrossingPolicy::AllowOvershoot { + return Err("spring overshoot requires positive damping so motion can settle".into()); + } + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn undamped_overshoot_is_rejected_for_both_motion_properties() { + for spring in [ + SpringSettings { + damping: Some(0.0), + overshoot: true, + ..Default::default() + }, + SpringSettings { + damping_ratio: Some(0.0), + overshoot: true, + ..Default::default() + }, + ] { + assert!( + MotionSettings { + spring, + ..Default::default() + } + .validate() + .is_err() + ); + assert!( + MotionSettings { + viewport_spring: spring, + ..Default::default() + } + .validate() + .is_err() + ); + } + assert!( + SpringSettings { + overshoot: true, + ..Default::default() + } + .resolve(SpringConfig { + damping: 0.0, + ..Default::default() + }) + .is_err() + ); + } + + #[test] + fn undamped_motion_needs_the_crossing_clamp_to_settle() { + use crate::AnimatedValue; + let clamped = SpringSettings { + damping: Some(0.0), + ..Default::default() + } + .resolve(SpringConfig::default()) + .unwrap(); + let mut closing = AnimatedValue { + current: 1.0, + target: 0.0, + velocity: 0.0, + }; + let mut free = closing; + for _ in 0..3600 { + let dt = std::time::Duration::from_secs_f64(1.0 / 60.0); + closing.advance(dt, clamped); + free.advance( + dt, + SpringConfig { + crossing: CrossingPolicy::AllowOvershoot, + ..clamped + }, + ); + assert!(free.current != free.target || free.velocity != 0.0); + } + assert_eq!(closing.current, closing.target); + assert_eq!(closing.velocity, 0.0); + } + + #[test] + fn viewport_legacy_damping_tracks_overridden_coefficients() { + let settings = MotionSettings { + viewport_spring: SpringSettings { + mass: Some(2.0), + stiffness: Some(500.0), + ..Default::default() + }, + ..Default::default() + }; + let spring = settings.viewport_config().unwrap(); + assert_eq!(spring.damping, 2.0 * 1000.0_f64.sqrt()); + assert_eq!(spring.crossing, CrossingPolicy::NoCrossing); + assert!(settings.validate().is_ok()); + } + + #[test] + fn policy_applies_speed_and_reduced_motion_consistently() { + let slow = MotionSettings { + speed: 0.5, + ..Default::default() + }; + assert_eq!(slow.duration(120.0), std::time::Duration::from_millis(240)); + assert!( + !MotionSettings { + reduced_motion: true, + ..slow + } + .motion_enabled() + ); + assert!( + MotionSettings { + reduced_motion: true, + ..slow + } + .duration(120.0) + .is_zero() + ); + assert!(MotionSettings { speed: 0.0, ..slow }.validate().is_err()); + } + #[test] + fn perceptual_conversion_and_legacy_defaults() { + let base = SpringConfig::default(); + assert_eq!(SpringSettings::default().resolve(base).unwrap(), base); + for (bounce, ratio) in [(0.0, 1.0), (0.3, 0.7), (-0.5, 2.0)] { + let spring = SpringSettings { + duration_ms: Some(500.0), + bounce: Some(bounce), + overshoot: bounce > 0.0, + ..Default::default() + } + .resolve(base) + .unwrap(); + assert!((spring.stiffness - 157.913670417).abs() < 1e-8); + assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - ratio).abs() < 1e-10); + assert!((std::f64::consts::TAU * (spring.mass / spring.stiffness).sqrt() - 0.5).abs() < 1e-10); + } + } + #[test] + fn rejects_ambiguous_or_nonsettling_configuration() { + for settings in [ + SpringSettings { + duration_ms: Some(300.0), + stiffness: Some(100.0), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(300.0), + bounce: Some(0.3), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(300.0), + bounce: Some(-1.0), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(300.0), + bounce: Some(1.0), + overshoot: true, + ..Default::default() + }, + ] { + assert!(settings.resolve(SpringConfig::default()).is_err()); + } + } +} diff --git a/crates/ferese-animation/src/traces.rs b/crates/ferese-animation/src/traces.rs index 44c92ad..73f3cea 100644 --- a/crates/ferese-animation/src/traces.rs +++ b/crates/ferese-animation/src/traces.rs @@ -110,7 +110,7 @@ fn capture_old_golden_trace_and_retarget_policy() { value.retarget_preserving_motion(-500.0); println!("old reversal: {}, {}", value.current, value.velocity); assert_eq!(value.current, position); - assert_eq!(value.velocity, velocity * 0.35); + assert_eq!(value.velocity, velocity); } #[test] @@ -282,7 +282,7 @@ fn retarget_golden_policy_values() { old.retarget_preserving_motion(2000.); close(old.velocity, 9837.123292018367, 1e-9); old.retarget_preserving_motion(-500.); - close(old.velocity, 3442.993152206428, 1e-9); + close(old.velocity, 9837.123292018367, 1e-9); } #[test] @@ -342,7 +342,7 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() { let before = value; value.retarget_preserving_motion(-500.0); assert_eq!(value.current, before.current); - assert_eq!(value.velocity, before.velocity * 0.35); + assert_eq!(value.velocity, before.velocity); clock.sample(Duration::from_millis(81), &mut value, SpringConfig::default()); assert_ne!(value.velocity, 0.0); @@ -350,11 +350,11 @@ fn rapid_retarget_trace_preserves_position_and_the_golden_policy() { value.current = 318.94300938241565; value.velocity = 9837.123292018367; value.retarget_preserving_motion(-500.0); - close(value.velocity, 3442.993152206428, 1e-9); + close(value.velocity, 9837.123292018367, 1e-9); } #[test] -fn interrupted_resize_and_zoom_targets_are_c0_continuous() { +fn interrupted_resize_and_zoom_targets_preserve_position_and_velocity() { let initial = Rect::new(0., 0., 400., 300.); let mut geometry = WindowGeometry::new(initial, None); geometry.set_logical_target(Rect::new(100., 50., 900., 600.), Duration::ZERO); @@ -366,8 +366,10 @@ fn interrupted_resize_and_zoom_targets_are_c0_continuous() { PresentationMode::Normal, ] { let before = (geometry.visual.current, geometry.decorations); + let velocity = geometry.visual.velocity; geometry.set_presentation_mode(Rect::new(200., 100., 700., 500.), mode, Duration::from_millis(40)); assert_eq!((geometry.visual.current, geometry.decorations), before); + assert_eq!(geometry.visual.velocity, velocity); geometry.advance(Duration::ZERO, SpringConfig::default(), true); assert_eq!((geometry.visual.current, geometry.decorations), before); geometry.advance(Duration::from_millis(16), SpringConfig::default(), true); diff --git a/docs/configuration.md b/docs/configuration.md index 62a0ad7..5f3a7fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,7 +114,8 @@ Changing default width preserves manually resized columns. ## Animations -Set animation speed in Settings → Motion or edit the `animations` block in +Continuum is Ferese's shared compositor and shell motion system. Set animation +speed in Settings → Motion or edit the `animations` block in `~/.config/ferese/config.kdl`. Changes apply live. Start with `speed` if you only want faster or slower transitions; you do not need to change the springs. @@ -151,7 +152,20 @@ You can omit either spring block to keep its defaults. | `animations.spring.damping` | number ≥ 0 | `53` | Spring damping | | `animations.viewport-spring.mass` | number > 0 | `1` | Scrolling spring mass | | `animations.viewport-spring.stiffness` | number > 0 | `320` | Scrolling stiffness | -| `animations.viewport-spring.damping-ratio` | number > 0 | `1` | `1` is critically damped | +| `animations.viewport-spring.damping-ratio` | number ≥ 0 | `1` | `1` is critically damped; zero requires overshoot disabled | + +Both `spring` and `viewport-spring` also accept these keys: + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `damping-ratio` | number ≥ 0 | Derived from coefficients for `spring`; `1` for `viewport-spring` | Alternative to `damping`; zero requires overshoot disabled | +| `duration-ms` | number > 0 | Unset | Response parameter instead of stiffness/damping; not a completion deadline | +| `bounce` | number strictly between −1 and 1 | `0` with `duration-ms` | Requires `duration-ms`; positive values also require overshoot | +| `overshoot` | boolean | `false` | Allow target crossings for this spring; requires positive damping | + +`mass`, `stiffness` and `damping` are accepted in both blocks. The viewport's +default damping ratio tracks changes to mass and stiffness unless you supply +`damping`, `damping-ratio` or `duration-ms` explicitly. ### Speed and reduced motion @@ -166,17 +180,44 @@ immediate. Reduced motion takes precedence over `enabled #true`; setting `speed 0` is invalid. These settings control Ferese's animations, not animations inside other applications. +Overview can reverse while moving, and workspace changes slide in their navigation +direction. Workspace and scrolling gestures carry their release velocity into +the settling spring. Scrolling gestures resist at viewport limits and spring back. +Window opening starts when content is available; closing waits for the application +to unmap, then animates its retained image. Input and application close requests +remain responsive while the animations finish. + ### Spring tuning `spring` controls window position and size motion, fullscreen/maximize zoom and -overview entrance motion. `viewport-spring` controls the scrolling viewport and -the column-width animation that runs with it. Shell popups and theme fades use -timed transitions, so the spring parameters do not change their curves. +overview entrance and dismissal, opening/closing windows, focus emphasis, shell +popups and notifications. `viewport-spring` controls the scrolling viewport, +workspace slides and the column-width animation that runs with scrolling. +Continuum shares the analytic spring solver and motion settings between the +compositor and shell. Small opacity changes (hover, dimming, theme fades) may +still use timed transitions. -Spring motion has no fixed duration. Its settling time depends on the distance -to the target, current velocity and the spring parameters. The `animations` -block has no settings for per-animation durations, custom curves or animation -shaders. +Spring settling time depends on distance, velocity and tolerances. In either +spring block, you can use duration and bounce instead of stiffness and damping: + +```kdl +animations { + spring { + duration-ms 300.0 + bounce 0.0 + } +} +``` + +`duration-ms` is a response parameter, not a fixed completion deadline. With +`T = duration-ms / 1000`, Continuum uses `omega = 2*pi/T`, +`stiffness = mass*omega^2` and `damping = 2*mass*omega*zeta`. +`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`. +Bounce defaults to zero and must be strictly between -1 and 1. Positive bounce +requires `overshoot #true` in that spring block. Do not combine `duration-ms` +with `stiffness`, `damping` or `damping-ratio`; existing physical settings keep +working. This conversion does not guarantee the same perceptual duration or +settling time as Apple's springs. With the other parameters unchanged, higher stiffness makes motion faster and higher mass makes it slower. Damping controls how quickly velocity dies away. @@ -184,14 +225,16 @@ The default window spring is slightly overdamped, close to critical damping; the viewport spring is critically damped. For `viewport-spring`, `damping-ratio 1.0` is critical damping, values below `1` -are underdamped and values above `1` are overdamped. The window spring uses a -damping coefficient instead: critical damping is +are underdamped and values above `1` are overdamped. Both spring blocks accept a +damping ratio or a damping coefficient, but not both; critical damping is `damping = 2 * sqrt(stiffness * mass)`. Ferese stops spring animations -at their first target crossing, so lowering damping does not make them bounce -past the target. +at their first target crossing by default. `overshoot #true` allows that spring +to move past its target; opacity remains bounded. Overshoot is never enabled globally. -Mass, stiffness, damping ratio and speed must be finite and greater than zero. -The window spring's damping coefficient may also be zero. Invalid edits leave +Mass, stiffness and speed must be finite and greater than zero. +Damping coefficients and damping ratios may be zero only with overshoot disabled, +so the first-crossing clamp can settle the animation. Overshoot requires positive +damping; otherwise closing popups could wait indefinitely for settlement. Invalid edits leave the last accepted configuration active. ## Appearance @@ -225,7 +268,7 @@ one appearance. | `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius | | `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows | | `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength | -| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming and focus-ring transition; 0 snaps | +| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming transition; 0 snaps | | `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font | | `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default | | `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox | diff --git a/scripts/tests/test_overview_damage_isolated.py b/scripts/tests/test_overview_damage_isolated.py index 710e441..ff6b0dc 100644 --- a/scripts/tests/test_overview_damage_isolated.py +++ b/scripts/tests/test_overview_damage_isolated.py @@ -2,7 +2,9 @@ FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \ FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_overview_damage_isolated.py -Requires a Wayland host, foot and dbus-daemon. Never connects clients to the host. +Set FERESE_TEST_TERMINAL=alacritty to use Alacritty instead of foot. +Requires a Wayland host, the selected terminal and dbus-daemon. +Never connects clients to the host. """ import errno import json @@ -22,6 +24,8 @@ class OverviewDamageTest(unittest.TestCase): repo = Path(__file__).resolve().parents[2] binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") + terminal = os.environ.get("FERESE_TEST_TERMINAL", "foot") + self.assertIn(terminal, ("foot", "alacritty")) children = [] with tempfile.TemporaryDirectory(prefix="ferese-overview-damage-") as temporary: root = Path(temporary) @@ -36,7 +40,7 @@ class OverviewDamageTest(unittest.TestCase): env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), - FERESE_TRACE_PERFORMANCE="1", RUST_LOG="ferese=info") + FERESE_TRACE_PERFORMANCE="1", RUST_LOG="ferese=info,ferese::nested_input=debug") env.pop("WAYLAND_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None) log_path = root / "compositor.log" @@ -71,6 +75,13 @@ class OverviewDamageTest(unittest.TestCase): wait_for(lambda: len(reports()) >= count + 2) return reports()[-1] + def assert_quiet(label, report): + print(label + ":", report, flush=True) + with self.subTest(stage=label): + self.assertEqual(report["frames"], 0, str(report) + "\n" + log_path.read_text()[-5000:]) + self.assertEqual(report["damaged_pixels"], 0, str(report)) + self.assertGreater(report["no_damage_frames"], 0, str(report)) + writer = None with log_path.open("w") as log: try: @@ -89,7 +100,9 @@ class OverviewDamageTest(unittest.TestCase): app = f"ferese.test.overview.{index}" client = (["sh", "-c", 'cat "$1"; sleep 120', "sh", str(fifo)] if index == 0 else ["sleep", "120"]) - launch(["foot", "--app-id", app, *client], stdout=log, stderr=log) + terminal_command = (["alacritty", "--class", app, "--command", *client] + if terminal == "alacritty" else ["foot", "--app-id", app, *client]) + launch(terminal_command, stdout=log, stderr=log) wait_for(lambda: any(window["app_id"] == app and window["width"] > 0 for window in command("get-windows"))) @@ -106,21 +119,31 @@ class OverviewDamageTest(unittest.TestCase): command("toggle-overview") time.sleep(2) quiet = stationary_interval() - self.assertEqual(quiet["frames"], 0, str(quiet)) - self.assertEqual(quiet["damaged_pixels"], 0, str(quiet)) - self.assertGreater(quiet["no_damage_frames"], 0, str(quiet)) + assert_quiet("Settled overview", quiet) count = len(reports()) os.write(writer, b"Live overview content must still update.\n") wait_for(lambda: len(reports()) > count) changed = reports()[-1] - self.assertGreater(changed["frames"], 0, str(changed)) - self.assertGreater(changed["damaged_pixels"], 0, str(changed)) + print("Live content update:", changed, flush=True) + with self.subTest(stage="Live content update"): + self.assertGreater(changed["frames"], 0, str(changed)) + self.assertGreater(changed["damaged_pixels"], 0, str(changed)) # The content commit must not start another perpetual repaint. quiet = stationary_interval() - self.assertEqual(quiet["frames"], 0, str(quiet)) - self.assertGreater(quiet["no_damage_frames"], 0, str(quiet)) + assert_quiet("Settled overview after content update", quiet) + + # Exercise spring dismissal and reverse it while still in + # flight, then dismiss again and check the settled desktop. + command("toggle-overview") + time.sleep(.08) + command("toggle-overview") + time.sleep(.08) + command("toggle-overview") + time.sleep(2) + quiet = stationary_interval() + assert_quiet("Settled desktop after overview reversals", quiet) finally: if writer is not None: os.close(writer) diff --git a/scripts/tests/test_unmapped_toplevel_isolated.py b/scripts/tests/test_unmapped_toplevel_isolated.py index c7b91d3..e6437e7 100644 --- a/scripts/tests/test_unmapped_toplevel_isolated.py +++ b/scripts/tests/test_unmapped_toplevel_isolated.py @@ -55,6 +55,7 @@ class UnmappedToplevelTest(unittest.TestCase): XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=host_display, + RUST_LOG="ferese=info,ferese::state::lifecycle=debug,ferese::state::animation=debug", ) compositor_binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") ctl_binary = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") @@ -117,6 +118,10 @@ class UnmappedToplevelTest(unittest.TestCase): # Leave a configure/resize outstanding when the buffer is detached. subprocess.run([str(ctl_binary), "toggle-maximized"], env=env, capture_output=True, check=True) + time.sleep(.08) + if phase == "detached": + self.assertIn("retained close presentation", (root / "compositor.log").read_text(), + "unmap lost the old buffer before snapshot capture") client.stdin.write("\n") client.stdin.flush() @@ -140,6 +145,11 @@ class UnmappedToplevelTest(unittest.TestCase): [str(ctl_binary), "get-focused-window"], env=env, text=True )) self.assertIsNone(focused, "focus history must not restore a destroyed window") + deadline = time.monotonic() + 3 + while "released close presentation" not in (root / "compositor.log").read_text(): + if time.monotonic() >= deadline: + self.fail("retained close texture did not settle and release") + time.sleep(.025) finally: if client and client.poll() is None: client.terminate() diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 52ad02d..8dbf911 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-animation = { path = "../../crates/ferese-animation" } ferese-theme-client = { path = "../../crates/ferese-theme-client" } ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-theme = { path = "../../crates/ferese-theme" } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 023728d..88d9c99 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -360,6 +360,7 @@ fn parse_document( .map_err(|error| ferese_config::Error::from(error.to_string())) { Ok(config) => { + config.animations.validate().map_err(ferese_config::Error::from)?; config.notifications.validate().map_err(ferese_config::Error::from)?; config.desktop_widgets.validate().map_err(ferese_config::Error::from)?; let mut theme = shell_theme(&config.theme); @@ -577,6 +578,17 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { + #[test] + fn rejects_undamped_overshoot_before_publication() { + for property in ["spring", "viewport-spring"] { + for coefficient in ["damping", "damping-ratio"] { + let source = format!("animations {{ {property} {{ {coefficient} 0; overshoot #true; }}; }}"); + let error = parse_test_source(&source).unwrap_err().to_string(); + assert!(error.contains("overshoot requires positive damping"), "{error}"); + } + } + } + #[test] fn desktop_clock_parses_and_rejects_invalid_reload_values() { let clock = parse_test_source( diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 1eaa276..e4b1b06 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -904,6 +904,7 @@ impl FereseShell { motion::configure(config.animations, config.theme.material_radius); if self.config.animations != config.animations { + self.notifications.update_motion_settings(config.animations); if let Some(menu) = &mut self.menu { menu.motion.update_settings(config.animations); } diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 344cc8b..ecd9486 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -1,45 +1,12 @@ -//! Draw-only popup transform; layout stays stable while its presentation animates. use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector}; use cosmic::{Element, Theme}; pub type Regions = std::sync::Arc>>; -#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] -#[serde(default)] -pub(crate) struct Settings { - enabled: bool, - reduced_motion: bool, - speed: f64, -} +pub(crate) use ferese_animation::MotionSettings as Settings; +use ferese_animation::{AnimatedValue, SpringConfig}; -impl Default for Settings { - fn default() -> Self { - Self { - enabled: true, - reduced_motion: false, - speed: 1.0, - } - } -} - -impl Settings { - fn hover_duration(self) -> std::time::Duration { - self.duration(120.0) - } - - pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration { - if !self.enabled || self.reduced_motion { - return std::time::Duration::ZERO; - } - let speed = if self.speed.is_finite() && self.speed > 0.0 { - self.speed.clamp(0.01, 100.0) - } else { - 1.0 - }; - std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed) - } -} static SETTINGS: std::sync::OnceLock> = std::sync::OnceLock::new(); static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits()); @@ -56,25 +23,29 @@ pub(crate) fn configure(settings: Settings, radius: f32) { .unwrap() = settings; } -pub(crate) fn notification_duration() -> std::time::Duration { - SETTINGS +pub(crate) fn settings() -> Settings { + *SETTINGS .get_or_init(|| std::sync::RwLock::new(Settings::default())) .read() .unwrap() - .duration(180.0) } pub(crate) struct PopupMotion { - start: f32, - target: f32, + value: AnimatedValue, started: Option, settings: Settings, } impl PopupMotion { pub(crate) fn update_settings(&mut self, settings: Settings) { - let now = std::time::Instant::now(); - self.start = self.progress_at(now); + self.update_settings_at(settings, std::time::Instant::now()); + } + + fn update_settings_at(&mut self, settings: Settings, now: std::time::Instant) { + self.value = self.sample(now); + // Stored velocities use simulation seconds. Preserve visible velocity + // when changing the clock rate during an in-flight transition. + self.value.velocity *= self.settings.speed / settings.speed; self.settings = settings; if self.started.is_some() { self.started = Some(now); @@ -83,8 +54,11 @@ impl PopupMotion { pub(crate) fn new(settings: Settings) -> Self { Self { - start: 0.0, - target: 1.0, + value: AnimatedValue { + current: 0.0, + target: 1.0, + velocity: 0.0, + }, started: None, settings, } @@ -96,36 +70,49 @@ impl PopupMotion { } } - pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { + fn sample(&self, now: std::time::Instant) -> AnimatedValue { + let mut value = self.value; let Some(started) = self.started else { - return self.start; + return value; }; - let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 }); - if duration.is_zero() { - return self.target; + if !self.settings.motion_enabled() { + value.snap(); + return value; } - let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0); - if t == 1.0 { - return self.target; - } - self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t) + let spring = self + .settings + .spring + .resolve(SpringConfig::default()) + .expect("validated motion settings"); + value.advance( + now.saturating_duration_since(started).mul_f64(self.settings.speed), + SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..spring + }, + ); + value + } + + pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 { + self.sample(now).current as f32 } pub(crate) fn progress(&self) -> f32 { self.progress_at(std::time::Instant::now()) } - pub(crate) fn closing(&self) -> bool { - self.target == 0.0 + self.value.target == 0.0 } - pub(crate) fn animating(&self) -> bool { - self.progress() != self.target + let value = self.sample(std::time::Instant::now()); + value.current != value.target || value.velocity != 0.0 } pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) { - self.start = self.progress_at(now); - self.target = target; + self.value = self.sample(now); + self.value.set_target(f64::from(target)); self.started = Some(now); } } @@ -158,7 +145,7 @@ pub(crate) fn button<'a, M: Clone + 'a>( duration: SETTINGS .get() .map_or_else(Settings::default, |settings| *settings.read().unwrap()) - .hover_duration(), + .duration(120.0), }) } @@ -506,6 +493,28 @@ impl widget::Operation for CollectRegions { #[cfg(test)] mod tests { + #[test] + fn zero_damping_with_crossing_clamp_finishes_popup_close() { + let settings = super::Settings { + spring: ferese_animation::SpringSettings { + damping: Some(0.0), + ..Default::default() + }, + ..Default::default() + }; + settings.validate().unwrap(); + let now = std::time::Instant::now(); + let mut motion = super::PopupMotion::new(settings); + motion.begin(now); + let opened = now + std::time::Duration::from_secs(1); + assert_eq!(motion.sample(opened).current, 1.0); + motion.retarget(0.0, opened); + let closed = motion.sample(opened + std::time::Duration::from_secs(1)); + assert!(motion.closing()); + assert_eq!(closed.current, closed.target); + assert_eq!(closed.velocity, 0.0); + } + use super::*; #[test] @@ -529,19 +538,48 @@ mod tests { } #[test] - fn popup_waits_for_configuration_then_opens_and_closes_faster() { + fn popup_waits_for_configuration_and_preserves_reversal_velocity() { let now = std::time::Instant::now(); let mut motion = PopupMotion::new(Settings::default()); assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0); motion.begin(now); - // Later configure/focus notifications must not restart the clock. - motion.begin(now + std::time::Duration::from_millis(90)); - assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001); - assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0); - motion.retarget(0.0, now + std::time::Duration::from_millis(200)); + let reverse = now + std::time::Duration::from_millis(90); + let before = motion.sample(reverse); + motion.begin(reverse); + assert_eq!(motion.sample(reverse), before); + motion.retarget(0.0, reverse); + let after = motion.sample(reverse); + assert_eq!(after.current, before.current); + assert_eq!(after.velocity, before.velocity); assert!(motion.closing()); - assert!((motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001); - assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0); + assert_eq!(motion.progress_at(reverse + std::time::Duration::from_secs(2)), 0.0); + } + + #[test] + fn live_speed_change_preserves_visible_velocity_and_reduced_motion_snaps() { + let now = std::time::Instant::now(); + let mut motion = PopupMotion::new(Settings::default()); + motion.begin(now); + let time = now + std::time::Duration::from_millis(90); + let before = motion.sample(time); + motion.update_settings_at( + Settings { + speed: 2.0, + ..Settings::default() + }, + time, + ); + let after = motion.sample(time); + assert_eq!(after.current, before.current); + assert_eq!(after.velocity * 2.0, before.velocity); + motion.update_settings_at( + Settings { + reduced_motion: true, + ..Settings::default() + }, + time, + ); + assert_eq!(motion.progress_at(time), 1.0); } #[test] @@ -560,7 +598,7 @@ mod tests { let before = motion.progress_at(reopen); motion.retarget(1.0, reopen); assert_eq!(motion.progress_at(reopen), before); - assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(1)), 1.0); + assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(2)), 1.0); for settings in [ Settings { reduced_motion: true, @@ -632,7 +670,7 @@ mod tests { speed: 0.75, ..Settings::default() } - .hover_duration(), + .duration(120.0), std::time::Duration::from_millis(160) ); for settings in [ @@ -645,9 +683,9 @@ mod tests { ..Settings::default() }, ] { - assert!(settings.hover_duration().is_zero()); + assert!(settings.duration(120.0).is_zero()); let mut hover = HoverState::default(); - assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration())); + assert!(!hover.advance(1.0, std::time::Instant::now(), settings.duration(120.0))); assert_eq!(hover.current, 1.0); } } diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index a88598f..bc86424 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -262,7 +262,7 @@ impl Service { struct Toast { id: u32, - born: Instant, + motion: super::motion::PopupMotion, closing: Option, remaining: Option, hovered: bool, @@ -320,30 +320,27 @@ impl Center { } } + pub fn update_motion_settings(&mut self, settings: super::motion::Settings) { + for toast in &mut self.toasts { + toast.motion.update_settings(settings); + } + } + pub fn unread(&self) -> u32 { self.entries.iter().filter(|notice| notice.unread).count() as u32 } pub fn visible(&self) -> impl Iterator { - let duration = super::motion::notification_duration(); self.toasts.iter().rev().filter_map(move |toast| { let notice = self.entries.iter().find(|notice| notice.id == toast.id)?; - let p = if duration.is_zero() { - if toast.closing.is_some() { 0.0 } else { 1.0 } - } else { - toast.closing.map_or_else( - || (toast.born.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0), - |closing| (1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0), - ) - }; - Some((notice, p * p * (3.0 - 2.0 * p))) + Some((notice, toast.motion.progress().clamp(0.0, 1.0))) }) } pub fn animating(&self) -> bool { self.toasts .iter() - .any(|toast| toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration()) + .any(|toast| toast.closing.is_some() || toast.motion.animating()) } pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> { @@ -433,7 +430,11 @@ impl Center { } } if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) { - toast.closing.get_or_insert_with(Instant::now); + if toast.closing.is_none() { + let now = Instant::now(); + toast.closing = Some(now); + toast.motion.retarget(0.0, now); + } } } @@ -499,7 +500,11 @@ impl Center { if show { self.toasts.push_back(Toast { id: notice.id, - born: Instant::now(), + motion: { + let mut motion = super::motion::PopupMotion::new(super::motion::settings()); + motion.begin(Instant::now()); + motion + }, closing: None, remaining: notice.timeout, hovered: group_hovered, @@ -604,11 +609,8 @@ impl Center { for id in expired { self.close(id, 1); } - self.toasts.retain(|toast| { - toast - .closing - .is_none_or(|closing| now.duration_since(closing) < super::motion::notification_duration()) - }); + self.toasts + .retain(|toast| toast.closing.is_none() || toast.motion.animating()); let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect(); self.entries .retain(|notice| !notice.transient || notice.live || visible.contains(¬ice.id)); diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index 6d8ff9f..ff763b8 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -26,9 +26,12 @@ impl FereseShell { self.calendar_offset = 0; } - let destroy = self.destroy_menu(); if !kind.available(&self.status) { - return destroy; + return Task::none(); + } + if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) { + self.notifications.history_open = false; + self.notifications.hovered = None; } if kind == Menu::Notifications && self.notifications.ready { @@ -36,14 +39,6 @@ impl FereseShell { } let notifications = self.sync_notification_surface(); - let id = window::Id::unique(); - self.menu = Some(OpenMenu { - id, - kind, - motion: crate::motion::PopupMotion::new(self.config.animations), - effects: None, - regions: Default::default(), - }); crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); self.status_error = None; let parent = self.bar_surface_id; @@ -70,6 +65,33 @@ impl FereseShell { } else { kind.height_limit() }; + let positioner = SctkPositioner { + anchor_rect: anchor, + anchor: 2u32.try_into().unwrap(), + gravity: 6u32.try_into().unwrap(), + offset: (anchor.width / 2, 8), + // The view's autosize widget supplies the active panel's bounds. + // Keep initial popup limits broad enough for later panel changes. + size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)), + constraint_adjustment: 3, + ..Default::default() + }; + if let Some(menu) = &mut self.menu { + menu.kind = kind; + menu.motion.retarget(1.0, Instant::now()); + return Task::batch([ + notifications, + cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner), + ]); + } + let id = window::Id::unique(); + self.menu = Some(OpenMenu { + id, + kind, + motion: crate::motion::PopupMotion::new(self.config.animations), + effects: None, + regions: Default::default(), + }); let action = cosmic::surface::action::app_popup::( |_| Default::default(), move |_| SctkPopupSettings { @@ -79,23 +101,11 @@ impl FereseShell { grab: true, close_with_children: false, input_zone: None, - positioner: SctkPositioner { - anchor_rect: anchor, - // Numeric protocol values avoid depending on libcosmic's private SCTK reexport. - anchor: 2u32.try_into().unwrap(), // bottom - gravity: 6u32.try_into().unwrap(), // bottom-left - offset: (anchor.width / 2, 8), - size_limits: Limits::NONE - .min_width(kind.width()) - .max_width(kind.width()) - .max_height(height_limit), - constraint_adjustment: 3, // slide X/Y, never flip above the bar - ..Default::default() - }, + positioner: positioner.clone(), }, Some(Box::new(Self::view_status_menu)), ); - Task::batch([destroy, notifications]).chain(cosmic::task::message(cosmic::Action::Surface(action))) + notifications.chain(cosmic::task::message(cosmic::Action::Surface(action))) } pub fn destroy_menu(&mut self) -> Task { @@ -136,7 +146,7 @@ impl FereseShell { pub fn animate_menu(&mut self) -> Task { if let Some(menu) = &self.menu { if let Some(effects) = &menu.effects { - let _ = effects.set_opacity(menu.progress()); + let _ = effects.set_opacity(menu.progress().clamp(0.0, 1.0)); } if menu.motion.closing() && !menu.animating() { return self.destroy_menu(); diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 63d3543..feaf438 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -295,7 +295,7 @@ impl FereseShell { if let Some(effects) = &entry.effects { let regions = entry.regions.lock().unwrap().clone(); let _ = effects.set_material_regions(®ions, ferese_surface_effects_v1::Role::Modal); - let _ = effects.set_opacity(modal.motion.progress()); + let _ = effects.set_opacity(modal.motion.progress().clamp(0.0, 1.0)); } } }