From 64a80dc069aa3607f844895da540bc80a3bc6fea Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 3 Oct 2026 04:22:36 +0100 Subject: [PATCH] Unify window presentation and frame-driven shell motion Share window geometry across overview, resize snapshots and retained closes. Advance close handoffs and group retained rendering by stacking position. Drive shell motion from surface frames, reuse settled widget content, and synchronize notification materials with each card's fade. Replace physical config keys with duration and bounce, and update documentation and regression coverage. --- compositor/src/config.rs | 22 +- compositor/src/config/input_motion.rs | 5 +- compositor/src/effects.rs | 60 +++- compositor/src/overview.rs | 121 +++++-- compositor/src/presentation.rs | 154 ++++++++ compositor/src/render/closing.rs | 143 +++++--- compositor/src/render/materials.rs | 18 +- compositor/src/render/mod.rs | 1 + compositor/src/render/overview.rs | 25 +- compositor/src/render/resources.rs | 4 +- compositor/src/render/scene.rs | 105 ++---- compositor/src/render/window_content.rs | 194 ++++++++++ compositor/src/state.rs | 47 ++- compositor/src/state/animation.rs | 37 +- compositor/src/state/lifecycle.rs | 56 +-- compositor/src/state/prediction.rs | 175 +++++---- compositor/src/state/window_registry.rs | 1 + compositor/src/state/windows.rs | 7 +- crates/ferese-animation/src/settings.rs | 284 +++++++-------- docs/configuration.md | 102 +++--- packaging/config.kdl | 10 + protocols/ferese-effects-v1.xml | 13 +- .../tests/test_overview_damage_isolated.py | 13 +- scripts/tests/test_shell_motion_isolated.py | 153 ++++++++ shell/ferese-shell/src/config.rs | 6 +- shell/ferese-shell/src/main.rs | 44 --- shell/ferese-shell/src/motion.rs | 33 +- shell/ferese-shell/src/motion/frames.rs | 331 ++++++++++++++++++ shell/ferese-shell/src/notification_ui.rs | 33 +- shell/ferese-shell/src/notifications.rs | 73 +++- shell/ferese-shell/src/status_ui/mod.rs | 23 +- shell/ferese-shell/src/surfaces.rs | 22 +- shell/ferese-shell/src/system_modal.rs | 27 +- 33 files changed, 1742 insertions(+), 600 deletions(-) create mode 100644 compositor/src/render/window_content.rs create mode 100644 scripts/tests/test_shell_motion_isolated.py create mode 100644 shell/ferese-shell/src/motion/frames.rs diff --git a/compositor/src/config.rs b/compositor/src/config.rs index e23fca6..b4b8be3 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -357,12 +357,12 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { #[test] - fn rejects_undamped_overshoot_before_publication() { + fn rejects_removed_physics_keys_before_publication() { for property in ["spring", "viewport-spring"] { - for coefficient in ["damping", "damping-ratio"] { + for coefficient in ["mass", "stiffness", "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}"); + let error = Config::parse_source(&source).unwrap_err().to_string(); + assert!(error.contains("unknown field"), "{error}"); } } } @@ -582,7 +582,7 @@ mod tests { #[test] fn parses_animation_policy_and_reduced_motion() { let config = parse( - "animations {\n speed 1.5\n reduced-motion #true\n spring {\n mass 2.0\n stiffness 500.0\n damping 40.0\n }\n}\n", + "animations {\n speed 1.5\n reduced-motion #true\n spring {\n duration-ms 300.0\n bounce 0.0\n }\n}\n", ); assert!(!config.animations_enabled()); @@ -590,9 +590,9 @@ mod tests { assert_eq!( config.spring_config().unwrap(), SpringConfig { - mass: 2.0, - stiffness: 500.0, - damping: 40.0, + mass: 1.0, + stiffness: (std::f64::consts::TAU / 0.3).powi(2), + damping: 2.0 * std::f64::consts::TAU / 0.3, ..SpringConfig::default() } ); @@ -601,7 +601,7 @@ mod tests { #[test] fn rejects_invalid_animation_numbers() { let speed = parse("animations {\n speed 0.0\n}\n"); - let damping = parse("animations {\n spring {\n damping -1.0\n }\n}\n"); + let damping = parse("animations {\n spring {\n duration-ms -1.0\n }\n}\n"); assert!(speed.animation_speed().is_err()); assert!(damping.spring_config().is_err()); @@ -1043,8 +1043,8 @@ mod tests { let spring = config.viewport_spring_config().unwrap(); assert_eq!(spring.mass, 1.0); - assert_eq!(spring.stiffness, 320.0); - assert!((spring.damping - 35.777_087_64).abs() < 0.000_001); + assert!((spring.stiffness - (std::f64::consts::TAU / 0.350).powi(2)).abs() < 1e-9); + assert!((spring.damping - 2.0 * spring.stiffness.sqrt()).abs() < 1e-9); } #[test] diff --git a/compositor/src/config/input_motion.rs b/compositor/src/config/input_motion.rs index c02253b..e64f9b6 100644 --- a/compositor/src/config/input_motion.rs +++ b/compositor/src/config/input_motion.rs @@ -52,10 +52,7 @@ impl Config { } pub fn spring_config(&self) -> Result { - self.animations - .spring - .resolve(SpringConfig::default()) - .map_err(ConfigError::Binding) + self.animations.spring_config().map_err(ConfigError::Binding) } pub fn viewport_spring_config(&self) -> Result { diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index 487e943..eaabf35 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -58,6 +58,7 @@ struct SurfaceEffectsState { role: Mutex>, regions: Mutex>>, opacity: Mutex, + region_opacities: Mutex>, presentation_supported: AtomicBool, dismissing: AtomicBool, } @@ -70,6 +71,7 @@ impl SurfaceEffectsState { role: Mutex::new(None), regions: Mutex::new(None), opacity: Mutex::new(1000), + region_opacities: Mutex::new(Vec::new()), presentation_supported: AtomicBool::new(false), dismissing: AtomicBool::new(false), } @@ -101,7 +103,7 @@ impl SurfaceEffectsUserData { } pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(3, ()); + display.create_global::(4, ()); display.create_global::(1, ()); } @@ -195,6 +197,31 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option> { }) } +pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec { + with_states(surface, |states| { + states + .data_map + .get::() + .map(|effects| effects.region_opacities.lock().unwrap().clone()) + .unwrap_or_default() + }) +} + +fn decode_region_opacities(bytes: &[u8]) -> Option> { + if !bytes.len().is_multiple_of(4) || bytes.len() > 32 * 4 { + return None; + } + bytes + .as_chunks::<4>() + .0 + .iter() + .map(|v| { + let value = u32::from_ne_bytes(*v); + (value <= 1000).then_some(value) + }) + .collect() +} + fn decode_regions(bytes: &[u8]) -> Option> { if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 { return None; @@ -339,6 +366,26 @@ impl Dispatch for Ferese { crate::backends::direct::render_surface(state, &surface); } } + ferese_surface_effects_v1::Request::SetRegionOpacities { opacities } => { + let Some(opacities) = decode_region_opacities(&opacities) else { + return; + }; + let changed = with_states(&surface, |states| { + let Some(effects) = states.data_map.get::() else { + return false; + }; + let mut old = effects.region_opacities.lock().unwrap(); + if *old == opacities { + return false; + } + *old = opacities; + effects.generation.fetch_add(1, Ordering::Release); + true + }); + if changed { + crate::backends::direct::render_surface(state, &surface); + } + } ferese_surface_effects_v1::Request::SetRegions { regions } => { let Some(regions) = decode_regions(®ions) else { return; @@ -400,6 +447,7 @@ fn detach(surface: &WlSurface) -> bool { return false; }; let role_changed = effects.set_role(None); + effects.region_opacities.lock().unwrap().clear(); effects.dismissing.store(false, Ordering::Release); let mut opacity = effects.opacity.lock().unwrap(); let opacity_changed = *opacity != 1000; @@ -441,6 +489,16 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) { mod tests { use super::*; + #[test] + fn region_opacities_validate_independent_fades() { + let bytes: Vec<_> = [0u32, 500, 1000].into_iter().flat_map(u32::to_ne_bytes).collect(); + assert_eq!(decode_region_opacities(&bytes), Some(vec![0, 500, 1000])); + assert_eq!(decode_region_opacities(&[]), Some(vec![])); + assert!(decode_region_opacities(&bytes[..11]).is_none()); + assert!(decode_region_opacities(&1001u32.to_ne_bytes()).is_none()); + assert!(decode_region_opacities(&vec![0; 33 * 4]).is_none()); + } + #[test] fn fractional_regions_validate_all_wire_values() { let region = [0.25f32, -0.75, 100.5, 40.25, 13.5]; diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 4dd73cd..f8d067b 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -359,18 +359,36 @@ impl OverviewState { true } + #[cfg(test)] pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect { 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, - }) + pub(crate) fn motion(&self) -> &OverviewMotion { + &self.motion + } + + fn seed_presentations(&mut self, windows: impl IntoIterator) { + for window in windows { + self.motion.presentations.entry(window.id).or_insert_with(|| { + // Opening scales the final presentation after overview. Remove + // it here so the same opening motion is not applied twice. + let scale = 0.97 + 0.03 * window.opacity.current; + let mut bounds = window.bounds; + bounds.velocity = crate::presentation::scaled_visual_velocity( + bounds.current, + bounds.velocity, + 1.0 / scale, + -0.03 * window.opacity.velocity / (scale * scale), + ); + bounds.current = crate::presentation::scaled_visual_rect(bounds.current, 1.0 / scale); + bounds.velocity.x /= OVERVIEW_MOTION_SPEED; + bounds.velocity.y /= OVERVIEW_MOTION_SPEED; + bounds.velocity.width /= OVERVIEW_MOTION_SPEED; + bounds.velocity.height /= OVERVIEW_MOTION_SPEED; + bounds + }); + } } fn enter( @@ -551,6 +569,12 @@ impl Ferese { self.advance_animations(std::time::Instant::now()); if active { + let presentations = self + .windows + .ordered_ids() + .filter_map(|id| self.current_window_presentation(id)) + .collect::>(); + self.overview.seed_presentations(presentations); if self.cancel_workspace_slides() { self.relayout(); } @@ -640,19 +664,7 @@ impl Ferese { } pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option { - let normal = self.windows.geometry(&id)?.visual.current; - let mut presented = self.overview.presented_rect(id, normal); - if !self.overview.is_presenting() { - let (x, y) = self.workspace_slide_offset(id); - presented.x += x; - presented.y += y; - } - 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, - )) + Some(self.current_window_presentation(id)?.bounds.current) } pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> { @@ -1021,9 +1033,21 @@ impl OverviewMotion { self.opacity.current.clamp(0.0, 1.0) as f32 } + pub(crate) fn presented_bounds(&self, id: WindowId, normal: AnimatedRect) -> AnimatedRect { + let Some(mut bounds) = self.presentations.get(&id).copied() else { + return normal; + }; + bounds.velocity.x *= OVERVIEW_MOTION_SPEED; + bounds.velocity.y *= OVERVIEW_MOTION_SPEED; + bounds.velocity.width *= OVERVIEW_MOTION_SPEED; + bounds.velocity.height *= OVERVIEW_MOTION_SPEED; + bounds + } + + #[cfg(test)] pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect { - let preview = self.presentations.get(&id).map_or(normal, |p| p.current); - preview + + self.presentations.get(&id).map_or(normal, |p| p.current) } pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool { @@ -1067,6 +1091,57 @@ impl OverviewMotion { mod tests { use super::*; + #[test] + fn overview_entry_inherits_visible_velocity_and_does_not_double_opening_scale() { + use crate::presentation::{WindowPresentation, scaled_visual_rect, scaled_visual_velocity}; + use ferese_animation::RectVelocity; + let normal = Rect::new(20.25, -3.5, 640.0, 480.0); + let velocity = RectVelocity { + x: 300.0, + y: -50.0, + width: 25.0, + height: 8.0, + }; + let opacity = AnimatedValue { + current: 0.5, + target: 1.0, + velocity: 0.8, + }; + let scale = 0.97 + 0.03 * opacity.current; + let id = WindowId(1); + let presentation = WindowPresentation { + id, + bounds: AnimatedRect { + current: scaled_visual_rect(normal, scale), + target: normal, + velocity: scaled_visual_velocity(normal, velocity, scale, 0.03 * opacity.velocity), + }, + opacity, + emphasis: AnimatedValue::new(1.0), + shadow: AnimatedValue::new(1.0), + scale_content: false, + native_size: None, + }; + let mut overview = OverviewState::default(); + overview.seed_presentations([presentation]); + overview.enter( + HashMap::from([(id, (normal, Rect::new(200.0, 100.0, 320.0, 240.0)))]), + Some(id), + true, + ); + let sampled = overview.motion.presented_bounds(id, AnimatedRect::new(normal)); + assert!((sampled.current.x - normal.x).abs() < 1e-10); + assert!((sampled.current.width - normal.width).abs() < 1e-10); + assert!((sampled.velocity.x - velocity.x).abs() < 1e-10); + assert!((sampled.velocity.width - velocity.width).abs() < 1e-10); + // Re-entering during dismissal must keep the current spring, not seed + // it from a stale desktop sample. + overview.exit(HashMap::from([(id, normal)]), true); + let before = overview.motion.presentations[&id]; + overview.seed_presentations([presentation]); + assert_eq!(overview.motion.presentations[&id], before); + } + #[test] fn tiny_overview_target_changes_still_get_a_settling_tick() { let mut overview = OverviewState::default(); diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index da39825..a2c23d8 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -17,6 +17,73 @@ use crate::render::SharedPixelShaderElement; pub(crate) const HANDOFF: Duration = Duration::from_millis(80); pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024; +/// One window identity and motion sample, regardless of whether its pixels +/// come from a live surface, a resize snapshot, or a retained close image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct WindowPresentation { + pub id: ferese_layout::WindowId, + pub bounds: ferese_animation::AnimatedRect, + pub opacity: ferese_animation::AnimatedValue, + pub emphasis: ferese_animation::AnimatedValue, + pub shadow: ferese_animation::AnimatedValue, + pub scale_content: bool, + pub native_size: Option, +} + +impl WindowPresentation { + pub fn alpha(self) -> f32 { + self.opacity.current.clamp(0.0, 1.0) as f32 + } + + pub fn focus(self) -> f64 { + self.emphasis.current.clamp(0.0, 1.0) + } + + /// A thumbnail is another view of the same content and lifecycle state. + pub fn thumbnail(mut self, rect: ferese_layout::Rect) -> Self { + self.bounds = ferese_animation::AnimatedRect::new(rect); + self.scale_content = true; + self.native_size = None; + self + } + + pub fn close(&mut self) { + self.bounds.set_target(scaled_visual_rect(self.bounds.current, 0.98)); + self.opacity.set_target(0.0); + self.emphasis.set_target(0.0); + self.shadow.set_target(0.0); + self.scale_content = true; + self.native_size = None; + } + + /// Shadow intensity and elevation may respond independently; its outline + /// always follows the window's shared rounded geometry. + pub fn shadow_factors(self) -> (f64, f64, f64) { + let strength = self.shadow.current.clamp(0.0, 1.0); + (0.75 + 0.25 * strength, 0.9 + 0.1 * strength, 0.8 + 0.2 * strength) + } +} + +// Response multipliers preserve damping ratio and the user's crossing policy. +// Emphasis responds sooner; the shadow settles a little later than the bounds. +pub(crate) fn emphasis_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig { + visual_response(base, 0.8) +} + +pub(crate) fn shadow_spring(base: ferese_animation::SpringConfig) -> ferese_animation::SpringConfig { + visual_response(base, 1.2) +} + +fn visual_response(base: ferese_animation::SpringConfig, response: f64) -> ferese_animation::SpringConfig { + ferese_animation::SpringConfig { + stiffness: base.stiffness / (response * response), + damping: base.damping / response, + position_tolerance: 0.001, + velocity_tolerance: 0.001, + ..base + } +} + pub(crate) fn take_output_feedback( state: &crate::Ferese, output: &Output, @@ -300,6 +367,93 @@ impl RenderElement for NativeTextureElement { #[cfg(test)] mod tests { + fn moving_presentation() -> WindowPresentation { + use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity}; + WindowPresentation { + id: ferese_layout::WindowId(7), + bounds: AnimatedRect { + velocity: RectVelocity { + x: 150.0, + y: -90.0, + width: 20.0, + height: 5.0, + }, + ..AnimatedRect::new(ferese_layout::Rect::new(12.25, -4.5, 601.75, 399.25)) + }, + opacity: AnimatedValue { + current: 0.7, + target: 1.0, + velocity: 0.4, + }, + emphasis: AnimatedValue { + current: 0.6, + target: 1.0, + velocity: 0.3, + }, + shadow: AnimatedValue { + current: 0.4, + target: 1.0, + velocity: 0.2, + }, + scale_content: false, + native_size: None, + } + } + + #[test] + fn thumbnail_and_close_keep_window_identity_and_live_motion() { + let live = moving_presentation(); + let thumbnail = live.thumbnail(ferese_layout::Rect::new(0.25, 8.5, 120.5, 80.25)); + assert_eq!(thumbnail.id, live.id); + assert_eq!(thumbnail.opacity, live.opacity); + assert_eq!(thumbnail.emphasis, live.emphasis); + assert_eq!(thumbnail.shadow, live.shadow); + let mut closing = live; + closing.close(); + assert_eq!(closing.id, live.id); + assert_eq!(closing.bounds.current, live.bounds.current); + assert_eq!(closing.bounds.velocity, live.bounds.velocity); + assert_eq!(closing.opacity.current, live.opacity.current); + assert_eq!(closing.opacity.velocity, live.opacity.velocity); + assert_eq!(closing.emphasis.velocity, live.emphasis.velocity); + assert_eq!(closing.shadow.velocity, live.shadow.velocity); + for scale in [1.0, 1.25, 1.5, 2.0] { + assert_eq!( + RoundedRect::new(live.bounds.current, (0, 0).into(), scale, 14.0), + RoundedRect::new(closing.bounds.current, (0, 0).into(), scale, 14.0) + ); + } + } + + #[test] + fn layered_responses_preserve_damping_and_settle_independently() { + use ferese_animation::{AnimatedValue, SpringConfig}; + let base = SpringConfig::default(); + let damping_ratio = |spring: SpringConfig| spring.damping / (2.0 * (spring.mass * spring.stiffness).sqrt()); + for spring in [emphasis_spring(base), shadow_spring(base)] { + assert!((damping_ratio(spring) - damping_ratio(base)).abs() < 1e-12); + assert_eq!(spring.crossing, base.crossing); + } + let mut emphasis = AnimatedValue { + current: 0.0, + target: 1.0, + velocity: 0.0, + }; + let mut shadow = emphasis; + let dt = Duration::from_millis(80); + emphasis.advance(dt, emphasis_spring(base)); + shadow.advance(dt, shadow_spring(base)); + assert!(emphasis.current > shadow.current); + let before = (emphasis.velocity, shadow.velocity); + emphasis.set_target(0.0); + shadow.set_target(0.0); + assert_eq!((emphasis.velocity, shadow.velocity), before); + emphasis.advance(Duration::from_secs(3), emphasis_spring(base)); + shadow.advance(Duration::from_secs(3), shadow_spring(base)); + assert!(!emphasis.is_animating()); + assert!(!shadow.is_animating()); + } + #[test] fn rounded_outline_clamps_after_physical_snapping() { // Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5. diff --git a/compositor/src/render/closing.rs b/compositor/src/render/closing.rs index d5224ac..95b48ac 100644 --- a/compositor/src/render/closing.rs +++ b/compositor/src/render/closing.rs @@ -1,21 +1,19 @@ use super::*; -use ferese_animation::{AnimatedRect, AnimatedValue, CrossingPolicy, SpringConfig}; +use ferese_animation::{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 presentation: crate::presentation::WindowPresentation, pub output: OutputId, pub below: Option, - pub bounds: AnimatedRect, - pub opacity: AnimatedValue, pub snapshot: ResizeSnapshot, - pub source: Rectangle, + // Keep a resize handoff's visible content when the client unmaps mid-resize. + pub handoff: Option<(ResizeSnapshot, Option)>, pub radius: f64, pub shape: CornerShape, - pub focus: f64, pub decorations: f64, pub dim: f64, pub fill: Option<[f32; 4]>, @@ -28,9 +26,22 @@ pub(crate) struct ClosedWindow { } impl ClosedWindow { + pub fn bytes(&self) -> usize { + self.snapshot.bytes() + self.handoff.as_ref().map_or(0, |(snapshot, _)| snapshot.bytes()) + } + pub fn advance(&mut self, delta: Duration, spring: SpringConfig) -> bool { - self.bounds.advance(delta, spring); - let opacity = self.opacity.advance_with_policy( + // The client is gone, so this handoff no longer waits for configure + // acknowledgements. Delta already includes the global motion speed. + if let Some((snapshot, _)) = &mut self.handoff { + snapshot.elapsed = snapshot.elapsed.saturating_add(delta); + snapshot.commit.increment(); + if snapshot.elapsed >= crate::presentation::HANDOFF { + self.handoff = None; + } + } + self.presentation.bounds.advance(delta, spring); + let opacity = self.presentation.opacity.advance_with_policy( delta, SpringConfig { position_tolerance: 0.001, @@ -39,58 +50,64 @@ impl ClosedWindow { }, CrossingPolicy::NoCrossing, ); + self.presentation + .emphasis + .advance(delta, crate::presentation::emphasis_spring(spring)); + self.presentation + .shadow + .advance(delta, crate::presentation::shadow_spring(spring)); self.snapshot.commit.increment(); - // Once invisible there is no reason to keep a texture (including with - // intentionally undamped, overshooting geometry configurations). + // Texture lifetime ends when the shared presentation becomes invisible. opacity } } -pub(super) fn elements( +pub(super) fn grouped_elements( state: &mut Ferese, renderer: &mut GlesRenderer, output: &Output, - below: Option, - live: &std::collections::HashSet, + live: impl IntoIterator, delta: Duration, -) -> Vec { - if state.session_lock.active() { - return Vec::new(); +) -> std::collections::HashMap, Vec> { + if state.render.closing.is_empty() || state.session_lock.active() { + return Default::default(); } let Some(output_id) = state.output_id(output) else { - return Vec::new(); + return Default::default(); }; let Some(output_geometry) = state.space.output_geometry(output) else { - return Vec::new(); + return Default::default(); }; 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() - }) + .filter(|window| window.output == output_id && window.snapshot.context == renderer.context_id().erased()) .cloned() .collect(); - let mut elements = Vec::new(); + if windows.is_empty() { + return Default::default(); + } + let live: std::collections::HashSet<_> = live.into_iter().collect(); + let mut groups = std::collections::HashMap::new(); for mut window in windows { + let anchor = window.below.filter(|id| live.contains(id)); + let elements: &mut Vec = groups.entry(anchor).or_default(); if !delta.is_zero() { window.advance(delta, state.spring_config); } - let visual = window.bounds.current; + let visual = window.presentation.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; + let alpha = window.presentation.alpha(); if let Some(dim) = window_tint_element( &mut state.render, renderer, - window.id, + window.presentation.id, constrain, corners, [0.0, 0.0, 0.0, window.dim as f32 * alpha], @@ -104,44 +121,53 @@ pub(super) fn elements( &mut state.render, theme, renderer, - window.id, + window.presentation.id, constrain, corners, scale, - theme.border_width + (theme.focus_ring_width - theme.border_width) * window.focus, + theme.border_width + (theme.focus_ring_width - theme.border_width) * window.presentation.focus(), theme.border_color.0, theme.border_gradient, - window.focus as f32, + window.presentation.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)], + if let Some((snapshot, native_size)) = &window.handoff { + let mut presentation = window.presentation; + presentation.scale_content = native_size.is_none(); + presentation.native_size = *native_size; + if let Some(element) = super::window_content::snapshot_element( + snapshot, + presentation, + corners, + scale, + output, + &programs, + crate::presentation::handoff_alpha(snapshot.elapsed), + ) { + elements.push(element.into()); } - .into(), - ); + } + if let Some(element) = super::window_content::snapshot_element( + &window.snapshot, + window.presentation, + corners, + scale, + output, + &programs, + 1.0, + ) { + elements.push(element.into()); + } if let Some(mut fill) = window.fill { fill[3] = alpha; if let Some(fill) = window_tint_element( &mut state.render, renderer, - window.id, + window.presentation.id, constrain, corners, fill, @@ -151,8 +177,8 @@ pub(super) fn elements( elements.push(fill.into()); } } - if let Some((surface, role, generation, opacity)) = &window.material { - if let Some((material, _)) = material_element_with_role( + if let Some((surface, role, generation, opacity)) = &window.material + && let Some((material, _)) = material_element_with_role( state, renderer, output, @@ -165,21 +191,22 @@ pub(super) fn elements( alpha, }, (*role, *generation, *opacity), - ) { - elements.push(material); - } + ) + { + elements.push(material); } let theme = &state.theme_settings; + let (offset_factor, blur_factor, opacity_factor) = window.presentation.shadow_factors(); if let Some(shadow) = window_shadow_element( &mut state.render, renderer, - window.id, + window.presentation.id, constrain, corners, scale, - theme.shadow_offset_y, - theme.shadow_blur, - theme.shadow_opacity * f64::from(alpha) * window.decorations, + theme.shadow_offset_y * offset_factor, + theme.shadow_blur * blur_factor, + theme.shadow_opacity * opacity_factor * f64::from(alpha) * window.decorations, theme.shadow_color.0, output, &programs, @@ -187,5 +214,5 @@ pub(super) fn elements( elements.push(shadow.into()); } } - elements + groups } diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index e758b7c..7df6a78 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -19,17 +19,27 @@ pub(super) fn append_material_surface( state.theme_settings.material_radius }; let regions = crate::effects::surface_regions(surface); + let opacities = crate::effects::surface_region_opacities(surface); let targets: Vec<_> = match ®ions { - None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius))], + None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius), 1.0)], Some(regions) => regions .iter() - .filter_map(|r| material_region_geometry(*r, geometry, content_origin, scale)) + .enumerate() + .filter_map(|(index, r)| { + material_region_geometry(*r, geometry, content_origin, scale).map(|(rect, corners)| { + ( + rect, + corners, + opacities.get(index).copied().unwrap_or(1000) as f32 / 1000.0, + ) + }) + }) .collect(), }; let materials: Vec<_> = targets .iter() .enumerate() - .filter_map(|(index, (rect, corners))| { + .filter_map(|(index, (rect, corners, alpha))| { material_element( state, renderer, @@ -40,7 +50,7 @@ pub(super) fn append_material_surface( corners: *corners, index, capture_geometry: geometry, - alpha: 1.0, + alpha: *alpha, }, ) }) diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index b9fcf61..b518d1c 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -1,6 +1,7 @@ mod backdrop; mod capture; mod closing; +mod window_content; pub(crate) use closing::ClosedWindow; #[cfg(test)] mod corner_tests; diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 626d376..156d945 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -142,7 +142,7 @@ pub(super) fn overview_strip_elements( if !state.window_belongs_to_output(id, output) { continue; } - let Some(rect) = frame.windows.get(&id).map(|sample| sample.rect) else { + let Some(rect) = frame.windows.get(&id).map(|sample| sample.presentation.bounds.current) else { continue; }; let Some((buffer, size)) = state.overview_window_label(id, scale, rect.width) else { @@ -228,12 +228,31 @@ pub(super) fn overview_strip_elements( let clip_changed = state .output_id(output) .is_some_and(|output| state.render.thumbnail_corner_changed(*id, output, shape)); + let Some(sample) = frame.windows.get(id) else { continue }; + let presentation = sample.presentation.thumbnail(*rect); + let corners = RoundedRect::new(presentation.bounds.current, output_geometry.loc, scale, 4.0) + .with_shape(shape); + if let Some(snapshot) = state.render.snapshot(id) + && snapshot.context == renderer.context_id().erased() + && (snapshot.scale - scale).abs() < 0.001 + && let Some(element) = super::window_content::snapshot_element( + snapshot, + presentation, + corners, + scale, + output, + &programs, + alpha * crate::presentation::handoff_alpha(snapshot.elapsed), + ) + { + elements.push(element.into()); + } elements.extend(rounded_window_elements( renderer, window, - RoundedRect::new(*rect, output_geometry.loc, scale, 4.0).with_shape(shape), + corners, scale, - alpha, + alpha * presentation.alpha(), // Crop/scale/alpha/geometry already track their own // damage. Role changes also alter the corner shader, // even when the client buffer itself is unchanged. diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index 13bc914..9a20120 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -76,7 +76,7 @@ impl RenderResources { .closing .iter() .filter(|window| window.output == id) - .map(|window| window.id) + .map(|window| window.presentation.id) .collect(); self.closing.retain(|window| window.output != id); for id in removed { @@ -139,7 +139,7 @@ impl RenderResources { .closing .iter() .filter(|window| &window.snapshot.context == context) - .map(|window| window.id) + .map(|window| window.presentation.id) .collect(); self.closing.retain(|window| &window.snapshot.context != context); for id in removed { diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index d4510aa..f482c8e 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 = sample.rect; - let close_alpha = sample.alpha; + let visual = sample.presentation.bounds.current; + let close_alpha = sample.presentation.alpha(); let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0); Some((window.clone(), id, sample, visual, decoration_progress, close_alpha)) @@ -145,16 +145,17 @@ pub(crate) fn sampled_output_elements( .collect::>() }; - let live = windows.iter().map(|(_, id, ..)| *id).collect(); + let mut closing = super::closing::grouped_elements( + state, + renderer, + output, + windows.iter().map(|(_, id, ..)| *id), + frame.delta, + ); for (window, id, sample, visual, decoration_progress, close_alpha) in windows { - elements.extend(super::closing::elements( - state, - renderer, - output, - Some(id), - &live, - frame.delta, - )); + if let Some(group) = closing.remove(&Some(id)) { + elements.extend(group); + } let constrain = rounded_visual_rect(visual, output_geometry.loc); let material_surface = window .toplevel() @@ -172,9 +173,8 @@ pub(crate) fn sampled_output_elements( let corners = RoundedRect::new(visual, output_geometry.loc, scale, window_radius).with_shape(shape); let corner_shape_changed = state.render.prepare_window_corners(id, shape); 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 = sample.scale_content; + let scale_content = sample.presentation.scale_content; let behavior = resize_content_behavior(scale_content); let dim = sample.dim; @@ -198,7 +198,7 @@ pub(crate) fn sampled_output_elements( let shadow_blur = state.theme_settings.shadow_blur; let shadow_opacity = state.theme_settings.shadow_opacity; let shadow_color = state.theme_settings.shadow_color.0; - let focus = sample.focus; + let focus = sample.presentation.focus(); let border_width = state.theme_settings.border_width + (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus; let border_color = state.theme_settings.border_color.0; @@ -222,6 +222,7 @@ pub(crate) fn sampled_output_elements( ) { elements.push(border.into()); } + let (offset_factor, blur_factor, opacity_factor) = sample.presentation.shadow_factors(); let shadow = window_shadow_element( &mut state.render, renderer, @@ -229,9 +230,9 @@ pub(crate) fn sampled_output_elements( constrain, corners, scale, - shadow_offset_y, - shadow_blur, - shadow_opacity * f64::from(close_alpha) * decoration_progress, + shadow_offset_y * offset_factor, + shadow_blur * blur_factor, + shadow_opacity * opacity_factor * f64::from(close_alpha) * decoration_progress, shadow_color, output, &programs, @@ -239,54 +240,19 @@ pub(crate) fn sampled_output_elements( 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 (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(), + && let Some(element) = super::window_content::snapshot_element( + snapshot, + sample.presentation, + corners, + scale, + output, + &programs, + crate::presentation::handoff_alpha(snapshot.elapsed), ) - .intersection(pixels); - if let Some(visible) = visible { - let clip = framebuffer_clip_rect( - pixels, - output.current_mode().unwrap().size, - output.current_transform().invert(), - ); - elements.push( - NativeTextureElement { - id: snapshot.id.clone(), - commit: snapshot.commit, - texture: snapshot.texture.clone(), - geometry: visible, - source: Rectangle::from_size(Size::from(( - 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()), - uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)], - } - .into(), - ); - } + { + elements.push(element.into()); } + elements.extend(rounded_window_elements( renderer, &window, @@ -297,7 +263,7 @@ pub(crate) fn sampled_output_elements( output, programs.clone(), behavior, - sample.native_size, + sample.presentation.native_size, )); let source = window.geometry().size; if material_surface.is_none() @@ -352,14 +318,9 @@ pub(crate) fn sampled_output_elements( )); } } - elements.extend(super::closing::elements( - state, - renderer, - output, - None, - &live, - frame.delta, - )); + if let Some(group) = closing.remove(&None) { + elements.extend(group); + } elements.extend(layer_elements( state, renderer, diff --git a/compositor/src/render/window_content.rs b/compositor/src/render/window_content.rs new file mode 100644 index 0000000..7efe1dc --- /dev/null +++ b/compositor/src/render/window_content.rs @@ -0,0 +1,194 @@ +//! Live, overview and retained views use the same snapshot mapping and clip. +use super::*; + +pub(super) fn snapshot_element( + snapshot: &ResizeSnapshot, + presentation: crate::presentation::WindowPresentation, + corners: RoundedRect, + scale: f64, + output: &Output, + programs: &RoundedClipPrograms, + handoff: f32, +) -> Option { + let pixels = corners.rect; + let size = snapshot.texture.size(); + let (sx, sy) = if presentation.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) = presentation.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)?; + let clip = framebuffer_clip_rect(pixels, output.current_mode()?.size, output.current_transform().invert()); + Some(NativeTextureElement { + id: snapshot.id.clone(), + commit: snapshot.commit, + texture: snapshot.texture.clone(), + geometry: visible, + source: Rectangle::from_size(Size::from(( + f64::from(visible.size.w) / sx, + f64::from(visible.size.h) / sy, + ))), + alpha: presentation.alpha() * handoff, + program: Some(programs.texture.clone()), + uniforms: vec![Uniform::new("clip_rect", clip), Uniform::new("radius", corners.radius)], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires an EGL rendering device"] + fn snapshot_views_keep_fractional_edges_and_close_pixels() { + use ferese_animation::{AnimatedRect, AnimatedValue}; + use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; + use smithay::output::{Mode, PhysicalProperties, Subpixel}; + let device = EGLDevice::enumerate().unwrap().last().expect("EGL device"); + let display = unsafe { EGLDisplay::new(device).unwrap() }; + let context = EGLContext::new(&display).unwrap(); + let mut renderer = unsafe { GlesRenderer::new(context).unwrap() }; + let output = Output::new( + "snapshot-test".into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(Mode { + size: (128, 128).into(), + refresh: 60_000, + }), + Some(Transform::Normal), + None, + Some((0, 0).into()), + ); + let mut resources = RenderResources::default(); + let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); + for scale in [1.0, 1.25, 1.5, 2.0] { + let live = crate::presentation::WindowPresentation { + id: ferese_layout::WindowId(1), + bounds: AnimatedRect::new(ferese_layout::Rect::new(20.25, 20.5, 32.25, 24.25)), + opacity: AnimatedValue::new(1.0), + emphasis: AnimatedValue::new(1.0), + shadow: AnimatedValue::new(1.0), + scale_content: false, + native_size: None, + }; + let corners = + RoundedRect::new(live.bounds.current, (0, 0).into(), scale, 8.0).with_shape(CornerShape::Continuous); + let size = corners.rect.size; + let mut texture: GlesTexture = renderer + .create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()) + .unwrap(); + { + let mut target = renderer.bind(&mut texture).unwrap(); + let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap(); + frame + .clear(Color32F::new(1.0, 0.0, 0.0, 1.0), &[Rectangle::from_size(size)]) + .unwrap(); + let _ = frame.finish().unwrap(); + } + let snapshot = ResizeSnapshot { + texture, + context: renderer.context_id().erased(), + id: Id::new(), + commit: CommitCounter::default(), + elapsed: Duration::ZERO, + last_tick: None, + scale, + }; + let mut closing = live; + closing.close(); + let mut pixels = Vec::new(); + for presentation in [live, closing] { + let element = + snapshot_element(&snapshot, presentation, corners, scale, &output, &programs, 1.0).unwrap(); + assert_eq!(element.geometry, corners.rect); + let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (128, 128).into()).unwrap(); + { + let mut target = renderer.bind(&mut texture).unwrap(); + let mut frame = renderer + .render(&mut target, (128, 128).into(), Transform::Normal) + .unwrap(); + let damage = Rectangle::from_size((128, 128).into()); + frame.clear(Color32F::TRANSPARENT, &[damage]).unwrap(); + draw_render_elements( + &mut frame, + scale, + &[AnimatedWindowRenderElement::from(element)], + &[damage], + ) + .unwrap(); + let _ = frame.finish().unwrap(); + } + let mapping = renderer + .copy_texture(&texture, Rectangle::from_size((128, 128).into()), Fourcc::Abgr8888) + .unwrap(); + pixels.push(renderer.map_texture(&mapping).unwrap().to_vec()); + } + assert_eq!(pixels[0], pixels[1], "close handoff changed pixels at scale {scale}"); + assert!(pixels[0].chunks_exact(4).any(|pixel| pixel[3] == 255)); + let mut retained = super::super::closing::ClosedWindow { + presentation: closing, + output: ferese_core::OutputId(1), + below: None, + snapshot: snapshot.clone(), + handoff: Some((snapshot.clone(), None)), + radius: 8.0, + shape: CornerShape::Continuous, + decorations: 1.0, + dim: 0.0, + fill: None, + material: None, + }; + // A partially faded resize must keep fading after unmap, including + // in predicted frames, without advancing authoritative state twice. + retained.handoff.as_mut().unwrap().0.elapsed = Duration::from_millis(20); + let mut predicted = retained.clone(); + predicted.advance(Duration::from_millis(20), Default::default()); + let faded = &predicted.handoff.as_ref().unwrap().0; + assert_eq!(faded.elapsed, Duration::from_millis(40)); + assert_eq!(retained.handoff.as_ref().unwrap().0.elapsed, Duration::from_millis(20)); + let element = snapshot_element( + faded, + predicted.presentation, + corners, + scale, + &output, + &programs, + crate::presentation::handoff_alpha(faded.elapsed), + ) + .unwrap(); + assert!((element.alpha - predicted.presentation.alpha() * 0.5).abs() < 1e-6); + assert_eq!(element.geometry, corners.rect); + retained.advance(Duration::from_millis(60), Default::default()); + assert!(retained.handoff.is_none()); + let thumbnail = live.thumbnail(ferese_layout::Rect::new(4.25, 3.75, 16.5, 12.25)); + let corners = RoundedRect::new(thumbnail.bounds.current, (0, 0).into(), scale, 4.0); + let element = snapshot_element(&snapshot, thumbnail, corners, scale, &output, &programs, 1.0).unwrap(); + assert_eq!(element.geometry, corners.rect); + assert!((element.source.size.w - f64::from(size.w)).abs() < 1e-9); + assert!((element.source.size.h - f64::from(size.h)).abs() < 1e-9); + } + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index d49ffb2..7ba65ed 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -160,7 +160,8 @@ impl FocusSwipe { 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. - let distance = if distance.abs() < 0.001 { + + if distance.abs() < 0.001 { if self.direction == Direction::Right { 64.0 } else { @@ -168,8 +169,7 @@ impl FocusSwipe { } } else { distance - }; - distance + } } fn position(&self) -> f64 { @@ -266,8 +266,12 @@ impl WorkspaceSlide { } fn position(&self, item: &WorkspaceSlideItem, delta: Duration) -> SlideOffset { + self.sample(item, delta).0 + } + + fn sample(&self, item: &WorkspaceSlideItem, delta: Duration) -> (SlideOffset, SlideOffset) { if let Some(progress) = self.held_progress { - return item.start.between(item.target, progress); + return (item.start.between(item.target, progress), item.velocity); } let mut x = AnimatedValue { current: item.start.x, @@ -281,10 +285,16 @@ impl WorkspaceSlide { }; x.advance(delta, self.spring); y.advance(delta, self.spring); - SlideOffset { - x: x.current, - y: y.current, - } + ( + SlideOffset { + x: x.current, + y: y.current, + }, + SlideOffset { + x: x.velocity, + y: y.velocity, + }, + ) } fn moving(&self) -> bool { @@ -1105,6 +1115,27 @@ impl ClientData for ClientState { #[cfg(test)] mod tests { + #[test] + fn slide_item_sampling_matches_advance_without_mutating_the_slide() { + let mut slide = WorkspaceSlide::new(None, WorkspaceId(1), WorkspaceId(2), SwipeDirection::Up); + slide.advance(Duration::from_millis(30)); + for held in [None, Some(0.35)] { + slide.held_progress = held; + for delta in [Duration::ZERO, Duration::from_millis(8), Duration::from_millis(400)] { + let before = (slide.items[0].start, slide.items[0].velocity); + let mut reference = slide.clone(); + reference.advance(delta); + for (item, advanced) in slide.items.iter().zip(&reference.items) { + let (position, velocity) = slide.sample(item, delta); + let expected = held.map_or(advanced.start, |p| advanced.start.between(advanced.target, p)); + assert_eq!(position, expected); + assert_eq!(velocity, advanced.velocity); + } + assert_eq!((slide.items[0].start, slide.items[0].velocity), before); + } + } + } + #[test] fn workspace_reversal_keeps_position_and_velocity() { let from = WorkspaceId(1); diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index cb728ee..98e67e0 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -200,16 +200,25 @@ impl Ferese { 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 - }, + crate::presentation::emphasis_spring(self.spring_config), ); } else { focus.snap(); } dim_changed |= previous != focus.current; + let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target)); + let previous = shadow.current; + let changed = shadow.target != target; + shadow.set_target(target); + if self.animations_enabled { + active_animation |= shadow.advance( + if changed { Duration::ZERO } else { delta }, + crate::presentation::shadow_spring(self.spring_config), + ); + } else { + shadow.snap(); + } + dim_changed |= previous != shadow.current; } for window in self.space.elements() { @@ -255,7 +264,7 @@ impl Ferese { let active = self.animations_enabled && !self.session_lock.active() && window.advance(delta, self.spring_config); if !active { - finished.push(window.id); + finished.push(window.presentation.id); } active_animation |= active; active @@ -608,6 +617,7 @@ fn record_needs_tick( if record.resize.is_some() || record.opening.is_some() || record.focus.as_ref().is_none_or(|motion| motion.needs_update(focus)) + || record.shadow.as_ref().is_none_or(|motion| motion.needs_update(focus)) { return true; } @@ -644,6 +654,7 @@ mod tests { WindowRecord { geometry: Some(geometry), focus: Some(AnimatedValue::new(1.0)), + shadow: Some(AnimatedValue::new(1.0)), dimming: Some(DimAnimation::new(0.0)), ..Default::default() } @@ -664,6 +675,20 @@ mod tests { assert!(!record_needs_tick(&record, 1.0, 0.15, || false)); } + #[test] + fn shadow_keeps_ticks_until_it_settles_after_emphasis() { + let mut record = settled(); + let shadow = record.shadow.as_mut().unwrap(); + shadow.current = 0.9; + shadow.velocity = 0.2; + assert!(record_needs_tick(&record, 1.0, 0.0, || false)); + record.shadow.as_mut().unwrap().advance( + Duration::from_secs(3), + crate::presentation::shadow_spring(SpringConfig::default()), + ); + assert!(!record_needs_tick(&record, 1.0, 0.0, || false)); + } + #[test] fn geometry_motion_client_waits_and_resize_barriers_cannot_be_skipped() { let mut record = settled(); diff --git a/compositor/src/state/lifecycle.rs b/compositor/src/state/lifecycle.rs index eb73089..27ff880 100644 --- a/compositor/src/state/lifecycle.rs +++ b/compositor/src/state/lifecycle.rs @@ -1,6 +1,4 @@ use super::*; -use ferese_animation::AnimatedRect; -use smithay::backend::renderer::Texture; use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes}; impl Ferese { @@ -31,7 +29,7 @@ impl Ferese { 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) { + if self.render.closing.iter().any(|window| window.presentation.id == id) || !self.window_content_ready(window) { return; } let Some(output) = self @@ -45,36 +43,19 @@ impl Ferese { let Some(output_id) = self.output_id(&output) else { return; }; - let Some(rect) = self.presented_window_rect(id) else { + let Some(mut presentation) = self.current_window_presentation(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 handoff_size = (!presentation.scale_content).then(|| { + presentation + .native_size + .unwrap_or_else(|| ClientSize::from_rect(presentation.bounds.current)) + }); + presentation.close(); let mut source_geometry = window.geometry(); if !self.overview.is_presenting() { let size = ClientSize::from_rect(geometry.visual.current); @@ -98,12 +79,7 @@ impl Ferese { .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::(); + + self.render.closing.iter().map(|window| window.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 { @@ -125,7 +101,12 @@ impl Ferese { match result { Ok(Some(snapshot)) => { tracing::debug!(?id, bytes = snapshot.bytes(), "retained close presentation"); - let size = snapshot.texture.size(); + let handoff = self + .render + .snapshot(&id) + .filter(|old| old.context == snapshot.context && (old.scale - scale).abs() < 0.001) + .cloned() + .map(|old| (old, handoff_size)); let material = window.toplevel().and_then(|toplevel| { let surface = toplevel.wl_surface(); let (role, generation) = crate::effects::surface_role(surface)?; @@ -149,16 +130,13 @@ impl Ferese { && (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, + presentation, output: output_id, below, - bounds, - opacity, snapshot, - source: Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into()), + handoff, radius, shape: crate::render::window_corner_shape(window), - focus, decorations, dim, fill, diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 1f04cb1..b87c8bb 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -12,15 +12,97 @@ pub(crate) struct FrameScene { pub(crate) struct WindowFrame { pub geometry: WindowGeometry, - pub rect: Rect, - pub scale_content: bool, - pub native_size: Option, - pub alpha: f32, - pub focus: f64, + pub presentation: crate::presentation::WindowPresentation, pub dim: f64, } impl Ferese { + pub(crate) fn current_window_presentation(&self, id: WindowId) -> Option { + let geometry = *self.windows.geometry(&id)?; + Some(self.compose_window_presentation( + id, + geometry, + self.overview.motion(), + self.workspace_slide_offset(id), + Duration::ZERO, + )) + } + + fn compose_window_presentation( + &self, + id: WindowId, + geometry: WindowGeometry, + overview: &crate::overview::OverviewMotion, + offset: (f64, f64), + delta: Duration, + ) -> crate::presentation::WindowPresentation { + let record = self.windows.record(id).expect("presentation has a window record"); + let mut bounds = overview.presented_bounds(id, geometry.visual); + if !overview.is_presenting() { + bounds.current.x += offset.0; + bounds.current.y += offset.1; + if let Some(workspace) = self.workspaces.workspace_for_window(id) + && let Some(output_id) = self.output_workspaces.output_for_workspace(workspace) + && let Some(slide) = self.workspace_slides.get(&output_id) + && let Some(item) = slide.items.iter().find(|item| item.workspace == workspace) + && let Some(area) = self + .outputs_by_id + .get(&output_id) + .and_then(|output| self.space.output_geometry(output)) + { + let (_, velocity) = slide.sample(item, delta); + bounds.velocity.x += velocity.x * f64::from(area.size.w); + bounds.velocity.y += velocity.y * f64::from(area.size.h); + } + } + let focused = if self.overview.is_active() { + self.overview_selected(id) + } else { + self.focused_window == Some(id) + }; + let mut opacity = record.opening.unwrap_or_else(|| AnimatedValue::new(1.0)); + let mut emphasis = record + .focus + .unwrap_or_else(|| AnimatedValue::new(if focused { 1.0 } else { 0.0 })); + let mut shadow = record.shadow.unwrap_or(emphasis); + if !self.animations_enabled { + opacity.snap(); + emphasis.snap(); + shadow.snap(); + } else if !delta.is_zero() { + opacity.advance( + delta, + SpringConfig { + position_tolerance: 0.00001, + velocity_tolerance: 0.00001, + ..self.spring_config + }, + ); + emphasis.advance(delta, crate::presentation::emphasis_spring(self.spring_config)); + shadow.advance(delta, crate::presentation::shadow_spring(self.spring_config)); + } + let presence_scale = 0.97 + 0.03 * opacity.current; + let native_size = + (!overview.is_presenting() && presence_scale != 1.0).then(|| ClientSize::from_rect(bounds.current)); + bounds.velocity = crate::presentation::scaled_visual_velocity( + bounds.current, + bounds.velocity, + presence_scale, + 0.03 * opacity.velocity, + ); + bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale); + bounds.target = bounds.current; + crate::presentation::WindowPresentation { + id, + bounds, + opacity, + emphasis, + shadow, + scale_content: overview.is_presenting(), + native_size, + } + } + fn output_window_records<'a>( &'a self, output: &'a Output, @@ -58,6 +140,10 @@ impl Ferese { .focus .as_ref() .is_some_and(|focus| focus.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })) + || record + .shadow + .as_ref() + .is_some_and(|shadow| shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })) || record.dimming.as_ref().is_some_and(|dim| { dim.needs_update(crate::dimming::target( self.inactive_dim, @@ -105,6 +191,7 @@ impl Ferese { || record.opening.is_some() || self.render.snapshot(&id).is_some() || record.focus.as_ref().is_some_and(|focus| focus.is_animating()) + || record.shadow.as_ref().is_some_and(|shadow| shadow.is_animating()) || record.dimming.as_ref().is_some_and(|dim| dim.is_animating()) { return true; @@ -178,74 +265,22 @@ impl Ferese { ) }; - let mut rect = overview.presented_rect(id, geometry.visual.current); - if !overview.is_presenting() { - let (x, y) = self - .workspaces - .workspace_for_window(id) - .and_then(|workspace| slide_offsets.get(&workspace).copied()) - .unwrap_or_default(); - rect.x += x; - rect.y += y; + let offset = self + .workspaces + .workspace_for_window(id) + .and_then(|workspace| slide_offsets.get(&workspace).copied()) + .unwrap_or_default(); + let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta); + let mut dim = record.dimming.clone(); + if let Some(dim) = &mut dim { + dim.predict(delta, self.inactive_dim.duration_ms); } - - 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 focused = if self.overview.is_active() { - self.overview_selected(id) - } else { - self.focused_window == Some(id) - }; - - let sample_dim = |motion: Option<&DimAnimation>, fallback| { - let Some(mut motion) = motion.cloned() else { - return fallback; - }; - - if !delta.is_zero() { - motion.predict(delta, self.inactive_dim.duration_ms); - } - - 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, - 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), + presentation, + dim: dim.map_or(0.0, |dim| dim.current), }, ); } @@ -439,7 +474,9 @@ mod tests { assert_eq!(offsets[&to].0, 1440.0); assert_eq!( state.sample_frame(&output, Duration::from_millis(8)).windows[&id] - .rect + .presentation + .bounds + .current .x, state.sample_frame(&output, Duration::from_millis(8)).windows[&id] .geometry diff --git a/compositor/src/state/window_registry.rs b/compositor/src/state/window_registry.rs index 4a65316..106c41c 100644 --- a/compositor/src/state/window_registry.rs +++ b/compositor/src/state/window_registry.rs @@ -22,6 +22,7 @@ pub(crate) struct WindowRecord { pub metadata_revision: u64, pub resize: Option, pub focus: Option, + pub shadow: Option, pub dimming: Option, pub opening: Option, pub mapped_once: bool, diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 17148ca..3ba02c4 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -495,12 +495,7 @@ impl Ferese { } let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum::() - + self - .render - .closing - .iter() - .map(|window| window.snapshot.bytes()) - .sum::(); + + self.render.closing.iter().map(|window| window.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; diff --git a/crates/ferese-animation/src/settings.rs b/crates/ferese-animation/src/settings.rs index 8982da3..98c9364 100644 --- a/crates/ferese-animation/src/settings.rs +++ b/crates/ferese-animation/src/settings.rs @@ -2,7 +2,7 @@ use crate::{CrossingPolicy, SpringConfig}; /// One configuration contract for compositor and out-of-process shell motion. #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct MotionSettings { pub enabled: bool, pub reduced_motion: bool, @@ -32,21 +32,17 @@ impl MotionSettings { 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.spring_config()?; self.viewport_config()?; Ok(()) } + pub fn spring_config(self) -> Result { + self.spring.resolve(240.0) + } + 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() - }) + self.viewport_spring.resolve(350.0) } /// Only small opacity changes should use a timed duration. @@ -59,73 +55,50 @@ impl MotionSettings { } #[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)] -#[serde(default)] +#[serde(default, deny_unknown_fields)] pub struct SpringSettings { - pub mass: Option, - pub stiffness: Option, - pub damping: Option, - pub damping_ratio: Option, pub duration_ms: Option, - pub bounce: Option, + pub bounce: f64, 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), + fn resolve(self, default_duration_ms: f64) -> Result { + let duration = self.duration_ms.unwrap_or(default_duration_ms); + if !duration.is_finite() || duration <= 0.0 { + return Err("spring duration-ms must be finite and positive".into()); + } + if !self.bounce.is_finite() || self.bounce <= -1.0 || self.bounce >= 1.0 { + return Err("spring bounce must be between -1 and 1 (exclusive)".into()); + } + if self.bounce > 0.0 && !self.overshoot { + return Err("positive spring bounce requires overshoot".into()); + } + let ratio = if self.bounce >= 0.0 { + 1.0 - self.bounce + } else { + 1.0 / (1.0 + self.bounce) + }; + let omega = std::f64::consts::TAU / (duration / 1000.0); + let config = SpringConfig { + mass: 1.0, + stiffness: omega * omega, + damping: 2.0 * omega * ratio, crossing: if self.overshoot { CrossingPolicy::AllowOvershoot } else { CrossingPolicy::NoCrossing }, - ..base + ..SpringConfig::default() }; - if !positive(config.mass) || !positive(config.stiffness) || !config.damping.is_finite() || config.damping < 0.0 + // Guard derived values too: extreme finite inputs can overflow or + // underflow. Every accepted presentation spring must dissipate motion. + if !config.stiffness.is_finite() + || config.stiffness <= 0.0 + || !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()); + return Err("spring duration-ms/bounce produce out-of-range coefficients".into()); } Ok(config) } @@ -134,17 +107,76 @@ impl SpringSettings { #[cfg(test)] mod tests { use super::*; + #[test] - fn undamped_overshoot_is_rejected_for_both_motion_properties() { + fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() { + let settings = MotionSettings::default(); + for (spring, duration) in [ + (settings.spring_config().unwrap(), 0.240), + (settings.viewport_config().unwrap(), 0.350), + ] { + assert_eq!(spring.mass, 1.0); + assert!((spring.damping / (2.0 * spring.stiffness.sqrt()) - 1.0).abs() < 1e-12); + assert!((std::f64::consts::TAU / spring.stiffness.sqrt() - duration).abs() < 1e-12); + assert_eq!(spring.crossing, CrossingPolicy::NoCrossing); + } + settings.validate().unwrap(); + } + + #[test] + fn perceptual_conversion_covers_under_critical_and_overdamping() { + 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, + overshoot: bounce > 0.0, + } + .resolve(240.0) + .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_nonsettling_and_out_of_range_settings_for_both_properties() { for spring in [ SpringSettings { - damping: Some(0.0), + bounce: 1.0, overshoot: true, ..Default::default() }, SpringSettings { - damping_ratio: Some(0.0), - overshoot: true, + bounce: -1.0, + ..Default::default() + }, + SpringSettings { + bounce: f64::NAN, + ..Default::default() + }, + SpringSettings { + bounce: 0.2, + ..Default::default() + }, + SpringSettings { + duration_ms: Some(0.0), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(-100.0), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(f64::INFINITY), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(f64::MIN_POSITIVE), + ..Default::default() + }, + SpringSettings { + duration_ms: Some(f64::MAX), ..Default::default() }, ] { @@ -165,64 +197,28 @@ mod tests { .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), + fn accepted_bounce_retargets_and_settles() { + let spring = SpringSettings { + bounce: 0.3, + overshoot: true, ..Default::default() } - .resolve(SpringConfig::default()) + .resolve(240.0) .unwrap(); - let mut closing = AnimatedValue { - current: 1.0, - target: 0.0, + let mut motion = crate::AnimatedValue { + current: 0.0, + target: 1.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()); + motion.advance(std::time::Duration::from_millis(50), spring); + let before = (motion.current, motion.velocity); + motion.set_target(0.0); + assert_eq!((motion.current, motion.velocity), before); + motion.advance(std::time::Duration::from_secs(3), spring); + assert!(!motion.is_animating()); } #[test] @@ -248,51 +244,9 @@ mod tests { .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()); - } + assert_eq!( + slow.spring_config().unwrap(), + MotionSettings::default().spring_config().unwrap() + ); } } diff --git a/docs/configuration.md b/docs/configuration.md index 5f3a7fc..3b7bf0b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,15 +126,15 @@ animations { speed 1.0 spring { - mass 1.0 - stiffness 700.0 - damping 53.0 + duration-ms 240.0 + bounce 0.0 + overshoot #false } viewport-spring { - mass 1.0 - stiffness 320.0 - damping-ratio 1.0 + duration-ms 350.0 + bounce 0.0 + overshoot #false } } ``` @@ -147,25 +147,21 @@ You can omit either spring block to keep its defaults. | `animations.enabled` | boolean | `true` | Enable motion | | `animations.reduced-motion` | boolean | `false` | Disable animated motion | | `animations.speed` | number > 0 | `1` | Higher is faster; `0.75` is slower | -| `animations.spring.mass` | number > 0 | `1` | Window-motion spring mass | -| `animations.spring.stiffness` | number > 0 | `700` | Spring stiffness | -| `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; zero requires overshoot disabled | +| `animations.spring.duration-ms` | number > 0 | `240` | Window and shell spring response | +| `animations.viewport-spring.duration-ms` | number > 0 | `350` | Scrolling and workspace spring response | -Both `spring` and `viewport-spring` also accept these keys: +Both `spring` and `viewport-spring` 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 | +| `duration-ms` | number > 0 | `240` or `350`, as above | Larger values give a slower response; not a completion deadline | +| `bounce` | number strictly between −1 and 1 | `0` | Zero is critical damping; positive values require overshoot | +| `overshoot` | boolean | `false` | Allow this spring to cross its target | -`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. +Use duration and bounce to configure spring physics. `mass`, `stiffness`, +`damping` and `damping-ratio` are internal solver parameters, not configuration +keys. Removed or misspelled animation keys are rejected. Invalid edits leave +the last accepted configuration active. ### Speed and reduced motion @@ -180,6 +176,12 @@ immediate. Reduced motion takes precedence over `enabled #true`; setting `speed 0` is invalid. These settings control Ferese's animations, not animations inside other applications. +Shell animations sample motion on each surface's redraw, paced by its Wayland +frame callbacks. They request animation frames only while moving. +Menus, modals, notifications and hover fades use this path instead of a fixed +16 ms animation timer. Clock, notification-expiry and service updates keep their +own schedules. + 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. @@ -187,6 +189,12 @@ Window opening starts when content is available; closing waits for the applicati to unmap, then animates its retained image. Input and application close requests remain responsive while the animations finish. +Live windows, overview thumbnails and retained images share a window identity +and presentation state. Entering overview inherits the window's visible position +and velocity, including motion from a workspace slide. Resize images use the same +content mapping in overview and remain visible if the window closes during a resize. +Content, clipping and borders use the same rounded rectangle at the output's scale. + ### Spring tuning `spring` controls window position and size motion, fullscreen/maximize zoom and @@ -197,45 +205,45 @@ 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 settling time depends on distance, velocity and tolerances. In either -spring block, you can use duration and bounce instead of stiffness and damping: +Focus emphasis and shadows have separate spring responses. Emphasis uses 80% of +the main spring's response time; shadows use 120%. These ratios preserve the +configured damping ratio and overshoot policy, and follow `speed` and reduced +motion. They are built-in tuning values, not additional configuration keys. +Focused windows use the theme's full shadow. Unfocused shadows use 75% of its +vertical offset, 90% of its blur radius and 80% of its opacity, with spring motion +between those values. Shadow motion does not change the content or border geometry. + +Spring settling time depends on distance, velocity and tolerances. `duration-ms` +controls the response, not a fixed completion deadline. Both default springs are +critically damped; the viewport has a slower response than windows and shell popups. + +To opt into bounce for one spring: ```kdl animations { spring { duration-ms 300.0 - bounce 0.0 + bounce 0.2 + overshoot #true } } ``` -`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. +requires `overshoot #true` in that spring block. Negative bounce adds overdamping. +Without overshoot, Ferese stops a spring at its first target crossing. Opacity +remains bounded even when geometry can overshoot. Overshoot is never enabled globally. -With the other parameters unchanged, higher stiffness makes motion faster and -higher mass makes it slower. Damping controls how quickly velocity dies away. -The default window spring is slightly overdamped, close to critical damping; -the viewport spring is critically damped. +For the solver, mass is fixed at `1`. With `T = duration-ms / 1000`, Continuum uses +`omega = 2*pi/T`, `stiffness = omega^2` and `damping = 2*omega*zeta`. +`zeta = 1-bounce` for nonnegative bounce, otherwise `zeta = 1/(1+bounce)`. +This conversion does not guarantee the same perceptual duration or settling time +as Apple's springs. -For `viewport-spring`, `damping-ratio 1.0` is critical damping, values below `1` -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 by default. `overshoot #true` allows that spring -to move past its target; opacity remains bounded. Overshoot is never enabled globally. - -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. +Duration and speed must be finite and greater than zero. Bounce must be finite. +Values that overflow or underflow the derived coefficients are rejected. The +allowed bounce range keeps damping positive, so undamped oscillation cannot +prevent popup cleanup. ## Appearance diff --git a/packaging/config.kdl b/packaging/config.kdl index 04eb71f..42a41e1 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -83,6 +83,16 @@ animations { enabled #true reduced-motion #false speed 0.9 + spring { + duration-ms 240.0 + bounce 0.0 + overshoot #false + } + viewport-spring { + duration-ms 350.0 + bounce 0.0 + overshoot #false + } } appearance { diff --git a/protocols/ferese-effects-v1.xml b/protocols/ferese-effects-v1.xml index 80ab1b8..c2f15f7 100644 --- a/protocols/ferese-effects-v1.xml +++ b/protocols/ferese-effects-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the Ferese effects capability. Roles describe compositor-owned appearance policy and do not @@ -48,7 +48,7 @@ - + The role affects only compositor rendering. It does not change surface geometry, input, focus, stacking, or the surface's Wayland role. @@ -98,5 +98,14 @@ + + + An array of native-endian uint32 opacity values from 0 to 1000, + indexed in set_regions order. Content is not affected. Missing entries + use 1000. At most 32 entries are allowed; invalid arrays are ignored. + These values multiply the surface opacity and persist until replaced. + + + diff --git a/scripts/tests/test_overview_damage_isolated.py b/scripts/tests/test_overview_damage_isolated.py index ff6b0dc..c44afc0 100644 --- a/scripts/tests/test_overview_damage_isolated.py +++ b/scripts/tests/test_overview_damage_isolated.py @@ -72,8 +72,17 @@ class OverviewDamageTest(unittest.TestCase): # The first report includes time before this call. The second # covers a full five seconds of the requested stationary scene. count = len(reports()) - wait_for(lambda: len(reports()) >= count + 2) - return reports()[-1] + for attempt in range(3): + wait_for(lambda: len(reports()) >= count + 2) + lines = log_path.read_text().splitlines() + boundaries = [index for index, line in enumerate(lines) + if "render performance" in line] + interval = lines[boundaries[-2] + 1:boundaries[-1]] + if not any("ferese::nested_input:" in line for line in interval): + return reports()[-1] + print("Discarding interval with host input; waiting for a quiet interval", flush=True) + count = len(boundaries) - 1 + self.fail("No input-free interval for the stationary overview check") def assert_quiet(label, report): print(label + ":", report, flush=True) diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py new file mode 100644 index 0000000..f4fb71c --- /dev/null +++ b/scripts/tests/test_shell_motion_isolated.py @@ -0,0 +1,153 @@ +"""Exercise real shell frame-driven motion on a private nested desktop and bus. + +FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py +Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus. +Protocol traces contain only this test's shell traffic, never the host desktop. +""" +import os +from pathlib import Path +import re +import signal +import subprocess +import tempfile +import time +import unittest + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_SHELL_MOTION") == "1", "opt-in nested shell test") +class ShellMotionTest(unittest.TestCase): + def test_notifications_and_modals_follow_frames_and_finish_closing(self): + repo = Path(__file__).resolve().parents[2] + binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese") + shell = repo / os.environ.get("FERESE_TEST_SHELL", "target/release/ferese-shell") + ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/release/feresectl") + children = [] + with tempfile.TemporaryDirectory(prefix="ferese-shell-motion-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text( + 'animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n') + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache"), RUST_LOG="ferese=info,ferese::nested_input=debug") + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + log_path = root / "session.log" + # No service directories: readiness checks must not auto-activate + # the host's notification daemon on this private bus. + bus_config = root / "bus.conf" + bus_config.write_text( + 'sessionunix:tmpdir=/tmp' + '' + '') + + def launch(command, **kwargs): + process = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + children.append(process) + return process + + def wait_for(predicate, timeout=15): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(.025) + trace = log_path.read_text() + relevant = '\n'.join(line for line in trace.splitlines() if any( + word in line for word in ('get_layer_surface', 'ERROR', 'WARN', 'notification'))) + self.fail("Timed out:\n" + relevant[-16000:] + '\n' + trace[-4000:]) + + def dbus(method, *args, check=True): + return subprocess.run(["gdbus", "call", "--session", "--dest", "org.freedesktop.Notifications", + "--object-path", "/org/freedesktop/Notifications", "--method", + "org.freedesktop.Notifications." + method, *args], + env=env, text=True, capture_output=True, timeout=5, check=check) + + def layer_surface(namespace): + matches = list(re.finditer(r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', log_path.read_text())) + # Wayland object IDs can be reused after destruction. Restrict + # assertions to this surface's lifetime in the trace. + return (matches[-1].group(1), matches[-1].start()) if matches else None + + def frames(surface): + identity, start = surface + return len(re.findall(r'wl_surface[@#]' + identity + r'\.frame\(', log_path.read_text()[start:])) + + def commits(surface): + identity, start = surface + return len(re.findall(r'wl_surface[@#]' + identity + r'\.commit\(', log_path.read_text()[start:])) + + def destroyed(surface): + identity, start = surface + return re.search(r'wl_surface[@#]' + identity + r'\.destroy\(', log_path.read_text()[start:]) is not None + + with log_path.open("w") as log: + try: + bus = launch(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, stderr=log, text=True) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + compositor = launch([str(binary), "--backend", "nested", "--grant-effects", "--grant-shell-control", + "--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=tiny-skia", str(shell)], + stdout=log, stderr=log) + wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0) + wait_for(lambda: layer_surface("ferese-shell-top-bar")) + time.sleep(.5) + reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout + notice = re.search(r'uint32 (\d+)', reply).group(1) + surface = wait_for(lambda: layer_surface("ferese-shell-notifications")) + time.sleep(1.5) + opening_frames = frames(surface) + self.assertGreater(opening_frames, 10, log_path.read_text()[-8000:]) + time.sleep(1) + for attempt in range(3): + trace_start = len(log_path.read_text()) + before = commits(surface) + time.sleep(2) + idle_frames = commits(surface) - before + if 'ferese::nested_input:' not in log_path.read_text()[trace_start:]: + break + else: + self.fail("Host input interrupted all three idle measurement intervals") + # Clock/status updates still redraw settled surfaces. Count + # commits: Iced can request two callbacks for one redraw. + self.assertLessEqual(idle_frames, 12, "settled toast kept requesting animation frames") + material_updates = log_path.read_text().count('.set_region_opacities(') + dbus("CloseNotification", notice) + wait_for(lambda: destroyed(surface), timeout=5) + self.assertGreater(log_path.read_text().count('.set_region_opacities(') - material_updates, 5, + "notification materials did not follow their fade") + print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True) + + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True) + modal = wait_for(lambda: layer_surface("ferese-system-modal")) + wait_for(lambda: frames(modal) >= 4) + time.sleep(.3) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True) + wait_for(lambda: destroyed(modal), timeout=5) + self.assertGreater(frames(modal), 10, "modal close did not follow frames") + self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) + self.assertNotIn("panicked at", log_path.read_text()) + print(f"Modal: {frames(modal)} callbacks; interrupted opening closed and destroyed", flush=True) + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=5) + if child.stdout is not None: + child.stdout.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 88d9c99..18bf03b 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -579,12 +579,12 @@ pub(crate) fn config_path() -> Option { #[cfg(test)] mod tests { #[test] - fn rejects_undamped_overshoot_before_publication() { + fn rejects_removed_physics_keys_before_publication() { for property in ["spring", "viewport-spring"] { - for coefficient in ["damping", "damping-ratio"] { + for coefficient in ["mass", "stiffness", "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}"); + assert!(error.contains("unknown field"), "{error}"); } } } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index e4b1b06..9a9ccea 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -355,15 +355,6 @@ impl cosmic::Application for FereseShell { control.subscription().map(|_| Message::ControlReady) }), ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), - if self - .system_modal - .as_ref() - .is_some_and(|modal| modal.motion.animating() || modal.motion.closing()) - { - cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimatePower) - } else { - Subscription::none() - }, self.notifications.subscription().map(Message::NotificationEvent), self.notifications .tick_subscription() @@ -399,45 +390,10 @@ impl cosmic::Application for FereseShell { } else { Subscription::none() }, - if self - .menu - .as_ref() - .is_some_and(|menu| menu.animating() || menu.motion.closing()) - { - cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu) - } else { - Subscription::none() - }, ]) } fn update(&mut self, message: Self::Message) -> Task { - if let Some(menu) = &self.menu - && let Some(effects) = &menu.effects - { - if let Err(error) = effects.set_opacity(menu.progress()) { - eprintln!("ferese-shell: could not update popup opacity: {error}"); - } - - let regions = menu.regions.lock().unwrap().clone(); - - if let Err(error) = effects.set_regions(®ions) { - eprintln!("ferese-shell: could not update card materials: {error}"); - } - } - - if let Some(surface) = &self.notification_surface - && let Some(effects) = &surface.effects - { - let regions = surface.regions.lock().unwrap().clone(); - - if let Err(error) = effects.set_regions(®ions) { - eprintln!("ferese-shell: could not update notification materials: {error}"); - } - } - - self.update_power_materials(); - match message { Message::ThemeChanged(snapshot) => { let mut config = self.config.clone(); diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index ecd9486..476a2d2 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -1,3 +1,6 @@ +mod frames; +pub(crate) use frames::frame_driven; + 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}; @@ -79,11 +82,7 @@ impl PopupMotion { value.snap(); return value; } - let spring = self - .settings - .spring - .resolve(SpringConfig::default()) - .expect("validated motion settings"); + let spring = self.settings.spring_config().expect("validated motion settings"); value.advance( now.saturating_duration_since(started).mul_f64(self.settings.speed), SpringConfig { @@ -105,8 +104,17 @@ impl PopupMotion { pub(crate) fn closing(&self) -> bool { self.value.target == 0.0 } + pub(crate) fn revision(&self) -> Option { + self.started + } + pub(crate) fn frame_active(&self, now: std::time::Instant) -> bool { + self.started.is_some() && self.animating_at(now) + } pub(crate) fn animating(&self) -> bool { - let value = self.sample(std::time::Instant::now()); + self.animating_at(std::time::Instant::now()) + } + pub(crate) fn animating_at(&self, now: std::time::Instant) -> bool { + let value = self.sample(now); value.current != value.target || value.velocity != 0.0 } @@ -238,11 +246,14 @@ impl Widget for Hover<'_, M> { shell: &mut Shell<'_, M>, viewport: &Rectangle, ) { - let now = std::time::Instant::now(); + let now = match event { + Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) => *at, + _ => std::time::Instant::now(), + }; let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport); let state = tree.state.downcast_mut::(); if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) { - shell.request_redraw_at(now + std::time::Duration::from_millis(16)); + shell.request_redraw(); } self.progress.set(state.current); self.content.as_widget_mut().update( @@ -494,10 +505,12 @@ impl widget::Operation for CollectRegions { #[cfg(test)] mod tests { #[test] - fn zero_damping_with_crossing_clamp_finishes_popup_close() { + fn duration_bounce_spring_finishes_popup_close() { let settings = super::Settings { spring: ferese_animation::SpringSettings { - damping: Some(0.0), + duration_ms: Some(240.0), + bounce: 0.3, + overshoot: true, ..Default::default() }, ..Default::default() diff --git a/shell/ferese-shell/src/motion/frames.rs b/shell/ferese-shell/src/motion/frames.rs new file mode 100644 index 0000000..d3f97ad --- /dev/null +++ b/shell/ferese-shell/src/motion/frames.rs @@ -0,0 +1,331 @@ +//! Surface-local animation sampling. Iced's Wayland backend gates NextFrame +//! redraws on that surface's frame callback; no animation timer is needed. +use super::*; +use std::time::Instant; + +pub(crate) fn frame_driven<'a, M: 'a>( + revision: Option, + build: impl Fn(Instant) -> Element<'a, M> + 'a, + active: impl Fn(Instant) -> bool + 'a, + after_frame: impl Fn(Instant) + 'a, + completion: impl Fn(Instant) -> Option + 'a, +) -> Element<'a, M> { + let now = Instant::now(); + let content = build(now); + let content_active = active(now); + Element::new(FrameDriven { + content, + content_active, + revision, + build: Box::new(build), + active: Box::new(active), + after_frame: Box::new(after_frame), + completion: Box::new(completion), + }) +} + +#[derive(Default)] +struct FrameState { + revision: Option, + last_frame: Option, + completed: bool, +} + +impl FrameState { + fn begin(&mut self, at: Instant, revision: Option) -> bool { + if self.revision != revision { + self.revision = revision; + self.last_frame = None; + self.completed = false; + } + if self.last_frame == Some(at) { + return false; + } + self.last_frame = Some(at); + true + } + + fn complete(&mut self, active: bool) -> bool { + if active { + self.completed = false; + return false; + } + !std::mem::replace(&mut self.completed, true) + } +} + +struct FrameDriven<'a, M> { + content: Element<'a, M>, + content_active: bool, + revision: Option, + build: Box Element<'a, M> + 'a>, + active: Box bool + 'a>, + after_frame: Box, + completion: Box Option + 'a>, +} + +impl<'a, M: 'a> Widget for FrameDriven<'a, M> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(FrameState::default()) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.content)] + } + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn a11y_nodes( + &self, + layout: Layout<'_>, + tree: &widget::Tree, + cursor: mouse::Cursor, + ) -> iced_accessibility::A11yTree { + self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + viewport: &Rectangle, + ) { + let redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event { + tree.state + .downcast_mut::() + .begin(*at, self.revision) + .then_some(*at) + } else { + None + }; + if let Some(at) = redraw { + // Only paint parameters depend on time. Preserve the child tree's + // focus/hover/scroll state and its existing layout. + let active = (self.active)(at); + // Include the final settled sample, then reuse the tree on hover + // and other redraws. Application updates build fresh content. + if active || self.content_active { + self.content = (self.build)(at); + tree.children[0].diff(&mut self.content); + } + self.content_active = active; + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event { + let active = (self.active)(*at); + if active { + shell.request_redraw(); + } + // Iced can replay the same redraw after a child invalidates layout. + // Keep its NextFrame request, but sample/publish only once. + if redraw.is_some() { + (self.after_frame)(*at); + } + if redraw.is_some() { + let completion = (self.completion)(*at); + if tree.state.downcast_mut::().complete(completion.is_none()) + && let Some(message) = completion + { + shell.publish(message); + } + } + } + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content + .as_widget() + .draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &cosmic::Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, viewport, translation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::renderer::Headless; + use cosmic::iced::window::{Event as WindowEvent, RedrawRequest}; + use std::{cell::RefCell, time::Duration}; + + #[test] + fn callbacks_sample_every_refresh_without_timers_and_stop_when_settled() { + let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.0), + Some("tiny-skia"), + )) + .unwrap(); + for hz in [60, 120, 144, 240] { + let start = Instant::now(); + let mut motion = PopupMotion::new(Settings::default()); + motion.begin(start); + let samples = RefCell::new(Vec::new()); + let mut element = frame_driven( + motion.revision(), + |at| { + samples.borrow_mut().push(motion.progress_at(at)); + cosmic::widget::Space::new().width(20.0).height(20.0).into() + }, + |at| motion.frame_active(at), + |_| {}, + |at| (!motion.frame_active(at)).then_some(()), + ); + let mut tree = widget::Tree::new(&element); + let node = element.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(100.0, 100.0)), + ); + let mut messages = Vec::new(); + let mut finished = false; + let mut clipboard = cosmic::iced::advanced::clipboard::Null; + samples.borrow_mut().clear(); + for frame in 0..=hz * 2 { + let at = start + Duration::from_secs_f64(frame as f64 / hz as f64); + let event = Event::Window(WindowEvent::RedrawRequested(at)); + let active = motion.frame_active(at); + for _ in 0..2 { + // Iced may replay a redraw after a layout/update pass. + let mut shell = Shell::new(&mut messages); + element.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard, + &mut shell, + &Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()), + ); + assert_eq!( + shell.redraw_request(), + if active { + RedrawRequest::NextFrame + } else { + RedrawRequest::Wait + } + ); + } + assert_eq!(samples.borrow().len(), frame as usize + 1); + if !active { + finished = true; + break; + } + assert!( + messages.is_empty(), + "motion should not publish application updates per frame" + ); + } + assert!(finished); + assert_eq!(messages.len(), 1, "only one completion update"); + let settled_builds = samples.borrow().len(); + // A later unrelated redraw must not restart motion or cleanup. + let mut shell = Shell::new(&mut messages); + element.as_widget_mut().update( + &mut tree, + &Event::Window(WindowEvent::RedrawRequested(start + Duration::from_secs(3))), + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard, + &mut shell, + &Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()), + ); + assert_eq!(shell.redraw_request(), RedrawRequest::Wait); + assert_eq!(messages.len(), 1); + assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content"); + let values = samples.borrow(); + assert!(values.windows(2).all(|pair| pair[1] >= pair[0])); + assert_eq!(values.last(), Some(&1.0)); + assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped"); + } + // Keep this mutable: the real render path shares the same renderer. + renderer.reset(Rectangle::default()); + } + + #[test] + fn new_transition_can_complete_after_previous_one_settled() { + let start = Instant::now(); + let mut state = FrameState::default(); + assert!(state.begin(start, Some(start))); + assert!(state.complete(false)); + assert!(!state.begin(start, Some(start))); + assert!(!state.complete(false)); + let next = start + Duration::from_millis(10); + assert!(state.begin(next, Some(next))); + assert!(!state.complete(true)); + assert!(state.begin(next + Duration::from_millis(400), Some(next))); + assert!(state.complete(false)); + assert!(!state.complete(false)); + } +} diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 807a6cf..bbd4db6 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -148,6 +148,9 @@ impl FereseShell { } pub(super) fn sync_notification_surface(&mut self) -> Task { + // Hidden toast surfaces receive no frame callbacks. Their closes have + // no visible transition to finish, so release them immediately. + self.notifications.tick(); if self.notifications.ready { self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), @@ -513,10 +516,36 @@ impl FereseShell { } pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action> { + motion::frame_driven( + self.notifications.motion_revision(), + |now| self.view_notifications_at(now), + |now| self.notifications.frame_active(now), + |now| { + if let Some(surface) = &self.notification_surface + && let Some(effects) = &surface.effects + { + let _ = effects.set_regions(&surface.regions.lock().unwrap()); + let _ = effects.set_region_opacities( + self.notifications + .popup_groups_at(now) + .iter() + .map(|(_, opacity, _)| *opacity), + ); + } + }, + |now| { + self.notifications + .has_finished_closes(now) + .then_some(cosmic::Action::App(Message::NotificationTick)) + }, + ) + } + + pub(super) fn view_notifications_at(&self, now: std::time::Instant) -> Element<'_, cosmic::Action> { if self.notifications.history_open { let palette = self.config.theme; let surface = self.menu.as_ref(); - let progress = surface.map_or(1.0, status_ui::OpenMenu::progress); + let progress = surface.map_or(1.0, |menu| menu.motion.progress_at(now)); let opacity = 1.0; let color = move |rgba| color_with_opacity(rgba, opacity); let primary = color(palette.text_primary); @@ -751,7 +780,7 @@ impl FereseShell { ) } else { let mut cards = column([]).spacing(8); - for (notice, opacity, count) in self.notifications.popup_groups() { + for (notice, opacity, count) in self.notifications.popup_groups_at(now) { cards = cards.push(self.notification_card(notice, opacity, false, count)); } motion::animated( diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index bc86424..bd6b5b2 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -330,22 +330,39 @@ impl Center { self.entries.iter().filter(|notice| notice.unread).count() as u32 } + #[cfg(test)] pub fn visible(&self) -> impl Iterator { + self.visible_at(Instant::now()) + } + + fn visible_at(&self, now: Instant) -> impl Iterator { self.toasts.iter().rev().filter_map(move |toast| { let notice = self.entries.iter().find(|notice| notice.id == toast.id)?; - Some((notice, toast.motion.progress().clamp(0.0, 1.0))) + Some((notice, toast.motion.progress_at(now).clamp(0.0, 1.0))) }) } - pub fn animating(&self) -> bool { + pub fn frame_active(&self, now: Instant) -> bool { + self.toasts.iter().any(|toast| toast.motion.frame_active(now)) + } + + pub fn has_finished_closes(&self, now: Instant) -> bool { self.toasts .iter() - .any(|toast| toast.closing.is_some() || toast.motion.animating()) + .any(|toast| toast.closing.is_some() && !toast.motion.animating_at(now)) + } + + pub fn motion_revision(&self) -> Option { + self.toasts.iter().filter_map(|toast| toast.motion.revision()).max() } pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> { + self.popup_groups_at(Instant::now()) + } + + pub fn popup_groups_at(&self, now: Instant) -> Vec<(&Notice, f32, usize)> { let mut groups: Vec<(&Notice, f32, usize)> = Vec::new(); - for (notice, opacity) in self.visible() { + for (notice, opacity) in self.visible_at(now) { if let Some(group) = groups.iter_mut().find(|(head, _, _)| head.app == notice.app) { group.2 += 1; } else { @@ -429,12 +446,12 @@ impl Center { self.command(Command::Close(id, revision, reason)); } } - if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) { - if toast.closing.is_none() { - let now = Instant::now(); - toast.closing = Some(now); - toast.motion.retarget(0.0, now); - } + if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) + && toast.closing.is_none() + { + let now = Instant::now(); + toast.closing = Some(now); + toast.motion.retarget(0.0, now); } } @@ -551,10 +568,6 @@ impl Center { } pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> { - if self.animating() { - return cosmic::iced::time::every(Duration::from_millis(16)).map(|_| ()); - } - fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream + use<> { let deadline = *deadline; cosmic::iced::futures::stream::once(async move { @@ -610,7 +623,7 @@ impl Center { self.close(id, 1); } self.toasts - .retain(|toast| toast.closing.is_none() || toast.motion.animating()); + .retain(|toast| toast.closing.is_none() || (!self.history_open && 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)); @@ -664,6 +677,36 @@ mod tests { } } + #[test] + fn hidden_toast_closes_do_not_wait_for_unavailable_frame_callbacks() { + let (mut center, _) = fixture(); + center.receive(notice(1)); + center.toasts.front_mut().unwrap().motion.begin(Instant::now()); + center.history_open = true; + center.close(1, 2); + center.tick(); + assert!(center.toasts.is_empty()); + assert!(center.expiry_deadline().is_none()); + assert!(!center.frame_active(Instant::now())); + assert_eq!(center.entries.len(), 1, "history retains the notice"); + } + + #[test] + fn closing_toast_can_finish_while_another_toast_is_still_moving() { + let (mut center, _) = fixture(); + center.receive(notice(1)); + let old = Instant::now() - Duration::from_secs(3); + center.toasts.front_mut().unwrap().motion.retarget(0.0, old); + center.toasts.front_mut().unwrap().closing = Some(old); + center.receive(notice(2)); + let now = Instant::now(); + assert!(center.frame_active(now)); + assert!(center.has_finished_closes(now)); + center.tick(); + assert!(!center.has_finished_closes(Instant::now())); + assert_eq!(center.toasts.len(), 1); + } + #[test] fn history_groups_expand_and_clear_without_removing_other_apps() { let (mut center, commands) = fixture(); diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index 50a171c..d1f00e8 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -131,12 +131,31 @@ impl OpenMenu { impl FereseShell { fn view_status_menu(&self) -> Element<'_, cosmic::Action> { + let Some(menu) = &self.menu else { return text("").into() }; + motion::frame_driven( + menu.motion.revision(), + |now| self.view_status_menu_at(now), + |now| menu.motion.frame_active(now), + |now| { + if let Some(effects) = &menu.effects { + let _ = effects.set_regions(&menu.regions.lock().unwrap()); + let _ = effects.set_opacity(menu.motion.progress_at(now).clamp(0.0, 1.0)); + } + }, + |now| { + (menu.motion.closing() && !menu.motion.animating_at(now)) + .then_some(cosmic::Action::App(Message::AnimateMenu)) + }, + ) + } + + fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { return text("").into(); }; if menu.kind == Menu::Notifications && self.notifications.ready { return cosmic::widget::autosize::autosize( - self.view_notifications(), + self.view_notifications_at(now), cosmic::iced::advanced::widget::Id::new("ferese-notification-center"), ) .limits( @@ -229,7 +248,7 @@ impl FereseShell { cosmic::widget::autosize::autosize( super::motion::animated( panel.into(), - menu.progress(), + menu.motion.progress_at(now), menu.regions.clone(), theme.material_radius, ), diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 31fcfc4..003ec54 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -481,6 +481,7 @@ pub(super) struct EffectsBinding { _queue: EventQueue, regions: std::cell::RefCell>>, opacity: std::cell::Cell>, + region_opacities: std::cell::RefCell>, } impl EffectsBinding { @@ -504,7 +505,7 @@ impl EffectsBinding { let connection = Connection::from_backend(backend); let (globals, queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 3..=3, ())?; + let manager = globals.bind::(&qh, 4..=4, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; @@ -523,9 +524,28 @@ impl EffectsBinding { _queue: queue, regions: Default::default(), opacity: std::cell::Cell::new(Some(opacity)), + region_opacities: Default::default(), }) } + pub(super) fn set_region_opacities( + &self, + values: impl IntoIterator, + ) -> Result<(), Box> { + let values: Vec = values + .into_iter() + .take(32) + .map(|value| (value.clamp(0.0, 1.0) * 1000.0).round() as u32) + .collect(); + if *self.region_opacities.borrow() != values { + self.surface + .set_region_opacities(values.iter().flat_map(|value| value.to_ne_bytes()).collect()); + self.connection.flush()?; + *self.region_opacities.borrow_mut() = values; + } + Ok(()) + } + pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box> { self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover) } diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index feaf438..ba05f6c 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -468,7 +468,32 @@ impl FereseShell { let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { return text("").into(); }; - let progress = modal.motion.progress(); + motion::frame_driven( + modal.motion.revision(), + move |now| self.view_system_modal_at(id, now), + |now| modal.motion.frame_active(now), + |now| { + if let Some(effects) = &surface.effects { + let _ = effects + .set_material_regions(&surface.regions.lock().unwrap(), ferese_surface_effects_v1::Role::Modal); + let _ = effects.set_opacity(modal.motion.progress_at(now).clamp(0.0, 1.0)); + } + }, + |now| { + (surface.primary && modal.motion.closing() && !modal.motion.animating_at(now)) + .then_some(cosmic::Action::App(Message::AnimatePower)) + }, + ) + } + + fn view_system_modal_at(&self, id: window::Id, now: Instant) -> Element<'_, cosmic::Action> { + let Some(modal) = &self.system_modal else { + return text("").into(); + }; + let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else { + return text("").into(); + }; + let progress = modal.motion.progress_at(now); let content: Element<'_, cosmic::Action> = if surface.primary { let theme = self.config.theme; let material = surface.effects.is_some();