diff --git a/compositor/src/grabs/move_grab.rs b/compositor/src/grabs/move_grab.rs index c1f08c5..d0301f8 100644 --- a/compositor/src/grabs/move_grab.rs +++ b/compositor/src/grabs/move_grab.rs @@ -7,7 +7,7 @@ use smithay::{ PointerInnerHandle, RelativeMotionEvent, }, reexports::wayland_server::protocol::wl_surface::WlSurface, - utils::{Logical, Point}, + utils::{Logical, Point, Size}, }; use crate::Ferese; @@ -16,6 +16,7 @@ pub struct MoveSurfaceGrab { pub start_data: GrabStartData, pub window: Window, pub initial_location: Point, + pub initial_size: Size, pub finished: bool, } @@ -30,10 +31,7 @@ impl PointerGrab for MoveSurfaceGrab { handle.motion(data, None, event); let location = self.initial_location.to_f64() + (event.location - self.start_data.location); let location = location.to_i32_round(); - let size = self.window.geometry().size; - - data.set_floating_window_geometry(&self.window, location, size); - data.space.map_element(self.window.clone(), location, true); + data.set_floating_window_geometry(&self.window, location, self.initial_size); } fn relative_motion( @@ -153,9 +151,6 @@ impl PointerGrab for MoveSurfaceGrab { return; } - let size = self.window.geometry().size; - data.set_floating_window_geometry(&self.window, self.initial_location, size); - data.space - .map_element(self.window.clone(), self.initial_location, true); + data.set_floating_window_geometry(&self.window, self.initial_location, self.initial_size); } } diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index 528053c..64b22e5 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -1,7 +1,7 @@ use std::cell::RefCell; use smithay::{ - desktop::{Space, Window}, + desktop::Window, input::pointer::{ AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, @@ -275,8 +275,6 @@ impl PointerGrab for ResizeSurfaceGrab { self.initial_rect.loc, self.initial_rect.size, ); - data.space - .map_element(self.window.clone(), self.initial_rect.loc, true); } } @@ -326,35 +324,10 @@ impl ResizeState { } } -pub fn handle_resize_commit(space: &mut Space, surface: &WlSurface) { - let Some(window) = space - .elements() - .find(|window| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == surface) - }) - .cloned() - else { - return; - }; - let Some(mut location) = space.element_location(&window) else { - return; - }; - let geometry = window.geometry(); - let Some((edges, initial_rect)) = ResizeState::with(surface, ResizeState::commit) else { - return; - }; - - if edges.left() { - location.x = initial_rect.loc.x + initial_rect.size.w - geometry.size.w; - } - if edges.top() { - location.y = initial_rect.loc.y + initial_rect.size.h - geometry.size.h; - } - if edges.left() || edges.top() { - space.map_element(window, location, false); - } +pub fn handle_resize_commit(surface: &WlSurface) { + // The compositor's visual rect owns the anchored edge. A delayed client + // commit must not move the window or change its stacking order. + ResizeState::with(surface, ResizeState::commit); } fn constrained_size( diff --git a/compositor/src/handlers/activation.rs b/compositor/src/handlers/activation.rs index 0d8d2d7..3a51710 100644 --- a/compositor/src/handlers/activation.rs +++ b/compositor/src/handlers/activation.rs @@ -54,7 +54,7 @@ impl XdgActivationHandler for Ferese { tracing::error!(%error, ?focused, "failed to update workspace focus"); return; } - self.space.raise_element(&window, true); + self.raise_window(&window, true); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial()); self.space.elements().for_each(|window| { diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 6df90ce..75c1398 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -12,7 +12,7 @@ use smithay::{ Resource, protocol::{wl_seat, wl_surface::WlSurface}, }, - utils::{Rectangle, Serial}, + utils::Serial, wayland::{ compositor::with_states, shell::xdg::decoration::XdgDecorationHandler, @@ -136,7 +136,7 @@ impl XdgShellHandler for Ferese { else { return; }; - let Some(initial_location) = self.space.element_location(&window) else { + let Some(initial_rect) = self.visual_rect_for_window(&window) else { return; }; if !self.is_floating_window(&window) { @@ -147,7 +147,8 @@ impl XdgShellHandler for Ferese { MoveSurfaceGrab { start_data, window, - initial_location, + initial_location: initial_rect.loc, + initial_size: initial_rect.size, finished: false, }, serial, @@ -183,13 +184,12 @@ impl XdgShellHandler for Ferese { else { return; }; - let Some(location) = self.space.element_location(&window) else { + let Some(rect) = self.visual_rect_for_window(&window) else { return; }; if !self.is_floating_window(&window) { return; } - let rect = Rectangle::new(location, window.geometry().size); pointer.set_grab( self, ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect), @@ -314,7 +314,7 @@ fn set_decoration_mode(toplevel: &ToplevelSurface, mode: Mode) { } pub fn handle_commit(popups: &mut PopupManager, space: &mut Space, surface: &WlSurface) { - handle_resize_commit(space, surface); + handle_resize_commit(surface); if let Some(window) = space .elements() .find(|window| { diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 8dce2d8..f481ce4 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -308,7 +308,7 @@ impl Ferese { { tracing::error!(%error, ?focused, "failed to update workspace focus"); } - self.space.raise_element(&window, true); + self.raise_window(&window, true); let surface = window .toplevel() .expect("mapped window has a toplevel") diff --git a/compositor/src/main.rs b/compositor/src/main.rs index ccd3588..2e72f0b 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -8,6 +8,7 @@ mod handlers; mod input; mod ipc; mod metrics; +mod stacking; mod state; mod window_rules; mod winit; diff --git a/compositor/src/stacking.rs b/compositor/src/stacking.rs new file mode 100644 index 0000000..7669d7a --- /dev/null +++ b/compositor/src/stacking.rs @@ -0,0 +1,48 @@ +use ferese_layout::WindowId; + +/// Persistent back-to-front order. Geometry updates never change this order. +#[derive(Default)] +pub(crate) struct WindowStack { + order: Vec, +} + +impl WindowStack { + pub fn insert(&mut self, id: WindowId) { + if !self.order.contains(&id) { + self.order.push(id); + } + } + + pub fn remove(&mut self, id: WindowId) { + self.order.retain(|candidate| *candidate != id); + } + + pub fn raise(&mut self, id: WindowId) { + self.remove(id); + self.insert(id); + } + + pub fn rank(&self, id: WindowId) -> usize { + self.order + .iter() + .position(|candidate| *candidate == id) + .unwrap_or(usize::MAX) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remapping_neighbours_does_not_change_order() { + let mut stack = WindowStack::default(); + stack.insert(WindowId(1)); + stack.insert(WindowId(2)); + stack.raise(WindowId(1)); + stack.insert(WindowId(2)); + assert!(stack.rank(WindowId(1)) > stack.rank(WindowId(2))); + stack.remove(WindowId(1)); + assert_eq!(stack.rank(WindowId(2)), 0); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 5efccce..4b5d586 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -45,7 +45,7 @@ use smithay::{ protocol::wl_surface::WlSurface, }, }, - utils::{Logical, Point, Size}, + utils::{Logical, Point, Rectangle, Size}, wayland::{ alpha_modifier::AlphaModifierState, compositor::{CompositorClientState, CompositorState, with_states}, @@ -118,7 +118,7 @@ pub struct Ferese { output_identity_ids: HashMap, pub window_ids: HashMap, pub window_geometry: HashMap, - pub(crate) window_decoration_progress: HashMap, + window_stack: crate::stacking::WindowStack, pub(crate) window_borders: HashMap, pub(crate) window_shadows: HashMap, pub(crate) rounded_clip_programs: HashMap, @@ -429,7 +429,7 @@ impl Ferese { output_identity_ids: HashMap::new(), window_ids: HashMap::new(), window_geometry: HashMap::new(), - window_decoration_progress: HashMap::new(), + window_stack: crate::stacking::WindowStack::default(), window_borders: HashMap::new(), window_shadows: HashMap::new(), rounded_clip_programs: HashMap::new(), @@ -634,11 +634,49 @@ impl Ferese { }) } + pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { + if let Some(id) = self.window_ids.get(window) { + self.window_stack.raise(*id); + } + self.space.raise_element(window, activate); + self.sync_window_stacking(); + } + + pub(crate) fn sync_window_stacking(&mut self) { + let mut windows = self.space.elements().cloned().collect::>(); + windows.sort_by_key(|window| { + let id = self.window_ids.get(window).copied(); + let priority = id + .and_then(|id| self.window_geometry.get(&id)) + .is_some_and(|geometry| geometry.is_fullscreen() || geometry.is_zooming()); + ( + priority, + id.map_or(usize::MAX, |id| self.window_stack.rank(id)), + ) + }); + for window in windows { + self.space.raise_element(&window, false); + } + } + pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> { let id = self.window_ids.get(window)?; self.window_geometry.get(id)?.visual_scale() } + pub(crate) fn visual_rect_for_window( + &self, + window: &Window, + ) -> Option> { + let id = self.window_ids.get(window)?; + let rect = self.window_geometry.get(id)?.visual.current; + let size = ClientSize::from_rect(rect); + Some(Rectangle::new( + (rect.x.round() as i32, rect.y.round() as i32).into(), + (size.width, size.height).into(), + )) + } + pub fn add_tiled_window(&mut self, window: Window) { let id = WindowId(self.next_window_id); self.next_window_id += 1; @@ -661,6 +699,7 @@ impl Ferese { } self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); if focus_new_window { self.focused_window = Some(id); } @@ -695,6 +734,7 @@ impl Ferese { } self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); self.space.map_element(window, (0, 0), false); self.relayout(); } @@ -821,7 +861,7 @@ impl Ferese { self.space.unmap_elem(window); self.window_geometry.remove(&id); - self.window_decoration_progress.remove(&id); + self.window_stack.remove(id); self.window_borders.remove(&id); self.window_shadows.remove(&id); self.closing_windows.remove(&id); @@ -918,8 +958,6 @@ impl Ferese { let rect = if workspace_fullscreen == Some(*id) { fullscreen_bounds - } else if workspace_fullscreen.is_some() { - continue; } else { match self.workspaces.placement(*id) { Some(WindowPlacement::Tiled) => { @@ -973,30 +1011,9 @@ impl Ferese { let now = self.start_time.elapsed(); let mut scrolling_world_x = HashMap::new(); - // map_element also raises windows. Keep relayout's HashMap iteration - // order from randomly changing the stack during overlapping animations. - let stacking_order = self - .space - .elements() - .enumerate() - .filter_map(|(index, window)| self.window_ids.get(window).map(|id| (*id, index))) - .collect::>(); - placements.sort_by_key(|(_, id, ..)| stacking_order.get(id).copied().unwrap_or(usize::MAX)); - let mut fullscreen_transitions = Vec::new(); + placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements { - let decoration_target = if is_fullscreen { 0.0 } else { 1.0 }; - let decorations = self - .window_decoration_progress - .entry(id) - .or_insert_with(|| AnimatedValue::new(decoration_target)); - if decorations.target != decoration_target { - fullscreen_transitions.push(window.clone()); - } - decorations.set_target(decoration_target); - if !self.animations_enabled { - decorations.snap(); - } // A viewport-coupled width must never override fullscreen/floating geometry. if scrolling.is_none() { self.viewport_coupled_widths.remove(&id); @@ -1007,9 +1024,13 @@ impl Ferese { .window_geometry .entry(id) .or_insert_with(|| WindowGeometry::new(rect, committed_size)); - let requested_size = geometry.set_logical_target(rect, now); + let mut requested_size = geometry.set_presentation_target(rect, is_fullscreen, now); if !self.animations_enabled { geometry.advance(Duration::ZERO, self.spring_config, false); + requested_size = geometry.presentation_size_request(now).or(requested_size); + } + if geometry.is_zooming() { + self.viewport_coupled_widths.remove(&id); } if let Some((workspace, world_x, viewport_x)) = scrolling { let restored_world_x = restored_scrolling_world_x( @@ -1027,15 +1048,17 @@ impl Ferese { if !self.animations_enabled { animated_world_x.snap(); } - geometry.visual.current.x = animated_world_x.current - viewport_x; - geometry.visual.velocity.x = 0.0; + if !geometry.is_zooming() { + geometry.visual.current.x = animated_world_x.current - viewport_x; + geometry.visual.velocity.x = animated_world_x.velocity; + } scrolling_world_x.insert(id, (workspace, animated_world_x)); - let coupled = couple_width - || self - .viewport_coupled_widths - .get(&id) - .is_some_and(|(previous_workspace, _)| *previous_workspace == workspace); + let coupled = !geometry.is_zooming() + && (couple_width + || self.viewport_coupled_widths.get(&id).is_some_and( + |(previous_workspace, _)| *previous_workspace == workspace, + )); if coupled { let width = self.viewport_coupled_widths.entry(id).or_insert_with(|| { (workspace, AnimatedValue::new(geometry.visual.current.width)) @@ -1099,11 +1122,7 @@ impl Ferese { } } self.scrolling_world_x = scrolling_world_x; - // Neighbours are remapped on fullscreen exit; the shrinking window must - // remain above them. Subsequent animation ticks preserve this ordering. - for window in fullscreen_transitions { - self.space.raise_element(&window, false); - } + self.sync_window_stacking(); crate::backends::direct::render_all(self); } @@ -1175,16 +1194,10 @@ impl Ferese { let mut settled_coupled_widths = Vec::new(); for (window, id) in windows { - if let Some(decorations) = self.window_decoration_progress.get_mut(&id) { - if self.animations_enabled { - active_animation |= decorations.advance(delta, self.spring_config); - } else { - decorations.snap(); - } - } let Some(geometry) = self.window_geometry.get_mut(&id) else { continue; }; + let zooming = geometry.is_zooming(); let coupled_target = self .viewport_coupled_widths @@ -1202,13 +1215,18 @@ impl Ferese { if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) && let Some(viewport) = self.viewport_animations.get(workspace) { - if self.animations_enabled { + if zooming { + world_x.current = geometry.visual.current.x + viewport.current; + world_x.velocity = geometry.visual.velocity.x + viewport.velocity; + } else if self.animations_enabled { active_animation |= world_x.advance(delta, self.spring_config); } else { world_x.snap(); } - geometry.visual.current.x = world_x.current - viewport.current; - geometry.visual.velocity.x = 0.0; + if !zooming { + geometry.visual.current.x = world_x.current - viewport.current; + geometry.visual.velocity.x = world_x.velocity - viewport.velocity; + } } if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) { let width_active = if self.animations_enabled { @@ -1233,6 +1251,14 @@ impl Ferese { "client did not commit the final configured size within 500 ms" ); } + if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed()) + && let Some(toplevel) = window.toplevel() + { + toplevel.with_pending_state(|state| { + state.size = Some((size.width, size.height).into()); + }); + toplevel.send_pending_configure(); + } let visual = geometry.visual.current; self.space.map_element( window, @@ -1243,6 +1269,7 @@ impl Ferese { for id in settled_coupled_widths { self.viewport_coupled_widths.remove(&id); } + self.sync_window_stacking(); active_animation } @@ -1302,7 +1329,7 @@ impl Ferese { }; self.focused_window = Some(next); - self.space.raise_element(&window, true); + self.raise_window(&window, true); self.seat .get_keyboard() .expect("seat has a keyboard") @@ -1585,11 +1612,18 @@ impl Ferese { .window_geometry .entry(id) .or_insert_with(|| WindowGeometry::new(rect, client_size(window))); - geometry.set_logical_target(rect, self.start_time.elapsed()); - geometry.visual.snap(); + if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed()) + && let Some(toplevel) = window.toplevel() + { + toplevel.with_pending_state(|state| { + state.size = Some((size.width, size.height).into()); + }); + toplevel.send_pending_configure(); + } self.scrolling_world_x.remove(&id); self.viewport_coupled_widths.remove(&id); self.space.map_element(window.clone(), location, false); + self.sync_window_stacking(); crate::backends::direct::render_all(self); } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 187a20d..1412958 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -264,6 +264,7 @@ struct RoundedSurfaceRenderElement { program: GlesTexProgram, clip_rect: [f32; 4], radius: f32, + clip_changed: bool, } impl Element for RoundedSurfaceRenderElement { @@ -292,11 +293,18 @@ impl Element for RoundedSurfaceRenderElement { scale: RenderScale, commit: Option, ) -> DamageSet { + if self.clip_changed { + return DamageSet::from_slice(&[Rectangle::from_size(self.inner.geometry(scale).size)]); + } self.inner.damage_since(scale, commit) } - fn opaque_regions(&self, _scale: RenderScale) -> OpaqueRegions { - OpaqueRegions::default() + fn opaque_regions(&self, scale: RenderScale) -> OpaqueRegions { + if self.radius == 0.0 { + self.inner.opaque_regions(scale) + } else { + OpaqueRegions::default() + } } fn alpha(&self) -> f32 { @@ -575,9 +583,9 @@ fn output_elements( let (close_scale, close_alpha) = state.closing_visual(id); let visual = scaled_visual_rect(visual, close_scale); let decoration_progress = state - .window_decoration_progress + .window_geometry .get(&id) - .map_or(1.0, |progress| progress.current.clamp(0.0, 1.0)); + .map_or(1.0, |geometry| geometry.decorations.clamp(0.0, 1.0)); Some((window.clone(), id, visual, decoration_progress, close_alpha)) }) @@ -641,6 +649,10 @@ fn output_elements( scale, window_radius, close_alpha, + state + .window_geometry + .get(&id) + .is_some_and(|geometry| geometry.presentation_changed), output, programs.texture.clone(), )); @@ -905,6 +917,7 @@ fn rounded_window_elements( scale: f64, requested_radius: f64, alpha: f32, + clip_changed: bool, output: &Output, program: GlesTexProgram, ) -> Vec { @@ -966,6 +979,7 @@ fn rounded_window_elements( program: program.clone(), clip_rect: clip, radius, + clip_changed, } .into() }), diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index ece79d3..f9b0aed 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -272,6 +272,18 @@ pub struct WindowGeometry { pub logical: Rect, pub visual: AnimatedRect, pub client: ClientGeometry, + pub decorations: f64, + pub presentation_changed: bool, + fullscreen: bool, + zoom: Option, +} + +/// Fullscreen geometry and decorations use one clock, including on reversal. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ZoomTransition { + from: Rect, + decorations_from: f64, + progress: AnimatedValue, } impl WindowGeometry { @@ -286,6 +298,10 @@ impl WindowGeometry { committed_size, waiting_since: None, }, + decorations: 1.0, + presentation_changed: true, + fullscreen: false, + zoom: None, } } @@ -298,17 +314,135 @@ impl WindowGeometry { self.client.request_size(size, now).then_some(size) } + pub fn set_presentation_target( + &mut self, + rect: Rect, + fullscreen: bool, + now: Duration, + ) -> Option { + let rect = normalized_rect(rect); + if self.fullscreen != fullscreen || (self.zoom.is_some() && self.logical != rect) { + let mut progress = AnimatedValue::new(0.0); + progress.set_target(1.0); + let from = self.visual.current; + let distance = [ + rect.x - from.x, + rect.y - from.y, + rect.width - from.width, + rect.height - from.height, + ]; + let velocity = self.visual.velocity; + let speed = [velocity.x, velocity.y, velocity.width, velocity.height]; + let squared_length = distance.iter().map(|value| value * value).sum::(); + if squared_length > 0.001 { + let projected = + distance.iter().zip(speed).map(|(d, v)| d * v).sum::() / squared_length; + // Keep forward momentum; soften a reversal without jumping position. + progress.velocity = if projected < 0.0 { + projected * 0.35 + } else { + projected + }; + } + self.zoom = Some(ZoomTransition { + from: self.visual.current, + decorations_from: self.decorations, + progress, + }); + } + self.fullscreen = fullscreen; + self.logical = rect; + self.visual.set_target(rect); + self.presentation_size_request(now) + } + + /// Pace intermediate resizes to client commits, but always deliver the final + /// size. Fast clients redraw near the presented size instead of one giant + /// buffer being stretched throughout the zoom. + pub fn presentation_size_request(&mut self, now: Duration) -> Option { + if self.zoom.is_some() && self.client.waiting_since.is_some() && !self.client.timed_out(now) + { + return None; + } + let rect = if self.zoom.is_some() { + self.visual.current + } else { + self.logical + }; + let size = ClientSize::from_rect(rect); + self.client.request_size(size, now).then_some(size) + } + + pub fn is_zooming(&self) -> bool { + self.zoom.is_some() + } + + pub fn is_fullscreen(&self) -> bool { + self.fullscreen + } + + pub fn follow_pointer(&mut self, rect: Rect, now: Duration) -> Option { + self.zoom = None; + let requested_size = self.set_logical_target(rect, now); + self.visual.snap(); + self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; + self.presentation_changed = true; + requested_size + } + pub fn advance( &mut self, delta: Duration, config: SpringConfig, animations_enabled: bool, + ) -> bool { + let previous = (self.visual.current, self.decorations); + let active = self.advance_presentation(delta, config, animations_enabled); + self.presentation_changed = previous != (self.visual.current, self.decorations); + active + } + + fn advance_presentation( + &mut self, + delta: Duration, + config: SpringConfig, + animations_enabled: bool, ) -> bool { if !animations_enabled { self.visual.snap(); + self.decorations = if self.fullscreen { 0.0 } else { 1.0 }; + self.zoom = None; return false; } + if let Some(zoom) = &mut self.zoom { + let active = zoom.progress.advance(delta, config); + let p = zoom.progress.current; + let target = self.visual.target; + let lerp = |a: f64, b: f64| a + (b - a) * p; + self.visual.current = Rect::new( + lerp(zoom.from.x, target.x), + lerp(zoom.from.y, target.y), + lerp(zoom.from.width, target.width), + lerp(zoom.from.height, target.height), + ); + self.visual.velocity = RectVelocity { + x: (target.x - zoom.from.x) * zoom.progress.velocity, + y: (target.y - zoom.from.y) * zoom.progress.velocity, + width: (target.width - zoom.from.width) * zoom.progress.velocity, + height: (target.height - zoom.from.height) * zoom.progress.velocity, + }; + self.decorations = lerp( + zoom.decorations_from, + if self.fullscreen { 0.0 } else { 1.0 }, + ); + if !active { + self.zoom = None; + self.visual.snap(); + } + return active; + } + self.visual.advance(delta, config) } @@ -407,6 +541,159 @@ fn finite_or(value: f64, fallback: f64) -> f64 { mod tests { use super::*; + #[test] + fn zoom_geometry_and_decorations_share_progress() { + let start = Rect::new(200.0, 40.0, 600.0, 800.0); + let end = Rect::new(0.0, 0.0, 1920.0, 1080.0); + let mut geometry = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); + geometry.set_presentation_target(end, true, Duration::ZERO); + for _ in 0..20 { + geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); + let progress = 1.0 - geometry.decorations; + let visual = geometry.visual.current; + assert!((visual.x - (start.x + (end.x - start.x) * progress)).abs() < 1e-9); + assert!( + (visual.width - (start.width + (end.width - start.width) * progress)).abs() < 1e-9 + ); + } + } + + #[test] + fn rapid_zoom_reversal_preserves_presented_frame_and_reaches_latest_target() { + let tiled = Rect::new(600.0, 20.0, 600.0, 900.0); + let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0); + let mut geometry = WindowGeometry::new(tiled, None); + for enabled in [true, false, true, false] { + let before = (geometry.visual.current, geometry.decorations); + geometry.set_presentation_target( + if enabled { fullscreen } else { tiled }, + enabled, + Duration::ZERO, + ); + assert_eq!((geometry.visual.current, geometry.decorations), before); + geometry.advance(Duration::from_millis(48), SpringConfig::default(), true); + } + for _ in 0..240 { + geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); + } + assert_eq!(geometry.visual.current, tiled); + assert_eq!(geometry.decorations, 1.0); + assert!(!geometry.is_zooming()); + } + + #[test] + fn slow_client_does_not_queue_intermediate_sizes_but_gets_final_size() { + let start = Rect::new(0.0, 0.0, 600.0, 800.0); + let end = Rect::new(0.0, 0.0, 1920.0, 1080.0); + let mut geometry = WindowGeometry::new(start, Some(ClientSize::from_rect(start))); + assert_eq!( + geometry.set_presentation_target(end, true, Duration::ZERO), + None + ); + geometry.advance(Duration::from_millis(16), SpringConfig::default(), true); + let first = geometry + .presentation_size_request(Duration::from_millis(16)) + .unwrap(); + assert_ne!(first, ClientSize::from_rect(end)); + geometry.advance(Duration::from_millis(16), SpringConfig::default(), true); + assert_eq!( + geometry.presentation_size_request(Duration::from_millis(32)), + None + ); + geometry.client.commit(first); + assert!( + geometry + .presentation_size_request(Duration::from_millis(32)) + .is_some() + ); + for _ in 0..240 { + geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); + } + assert_eq!( + geometry.presentation_size_request(Duration::from_secs(2)), + Some(ClientSize::from_rect(end)) + ); + } + + #[test] + fn drag_takes_ownership_from_an_unfinished_zoom() { + let start = Rect::new(0.0, 0.0, 600.0, 800.0); + let mut geometry = WindowGeometry::new(start, None); + geometry.set_presentation_target(Rect::new(0.0, 0.0, 1920.0, 1080.0), true, Duration::ZERO); + geometry.advance(Duration::from_millis(32), SpringConfig::default(), true); + geometry.set_presentation_target(start, false, Duration::ZERO); + let dragged = Rect::new(40.0, 50.0, 500.0, 700.0); + geometry.follow_pointer(dragged, Duration::ZERO); + assert!(!geometry.advance(Duration::from_millis(16), SpringConfig::default(), true)); + assert_eq!(geometry.visual.current, dragged); + } + + #[test] + fn reduced_motion_finishes_zoom_and_decorations_together() { + let mut geometry = WindowGeometry::new(Rect::new(100.0, 20.0, 600.0, 800.0), None); + let target = Rect::new(0.0, 0.0, 1920.0, 1080.0); + geometry.set_presentation_target(target, true, Duration::ZERO); + geometry.advance(Duration::ZERO, SpringConfig::default(), false); + assert_eq!(geometry.visual.current, target); + assert_eq!(geometry.decorations, 0.0); + assert!(!geometry.is_zooming()); + } + + #[test] + fn relayout_during_zoom_does_not_restart_its_clock() { + let start = Rect::new(600.0, 20.0, 600.0, 900.0); + let target = Rect::new(0.0, 0.0, 1920.0, 1080.0); + let mut uninterrupted = WindowGeometry::new(start, None); + uninterrupted.set_presentation_target(target, true, Duration::ZERO); + let mut relayout = uninterrupted; + for frame in 0..120 { + let now = Duration::from_millis(frame * 8); + relayout.set_presentation_target(target, true, now); + relayout.advance(Duration::from_millis(8), SpringConfig::default(), true); + uninterrupted.advance(Duration::from_millis(8), SpringConfig::default(), true); + assert_eq!(relayout.visual, uninterrupted.visual); + assert_eq!(relayout.decorations, uninterrupted.decorations); + } + } + + #[test] + fn zoom_at_different_refresh_rates_has_comparable_progress() { + let start = Rect::new(600.0, 20.0, 600.0, 900.0); + let target = Rect::new(0.0, 0.0, 1920.0, 1080.0); + let mut progress = Vec::new(); + for rate in [60, 120, 144] { + let mut geometry = WindowGeometry::new(start, None); + geometry.set_presentation_target(target, true, Duration::ZERO); + for _ in 0..rate / 6 { + geometry.advance( + Duration::from_secs_f64(1.0 / f64::from(rate)), + SpringConfig::default(), + true, + ); + } + progress.push(geometry.decorations); + } + assert!((progress[0] - progress[1]).abs() < 0.01); + assert!((progress[0] - progress[2]).abs() < 0.01); + } + + #[test] + fn final_frame_invalidates_clip_even_when_zoom_has_settled() { + let start = Rect::new(0.0, 0.0, 600.0, 800.0); + let mut geometry = WindowGeometry::new(start, None); + geometry.set_presentation_target(start, true, Duration::ZERO); + for _ in 0..1000 { + if !geometry.advance(Duration::from_millis(8), SpringConfig::default(), true) { + assert!(geometry.presentation_changed); + assert_eq!(geometry.decorations, 0.0); + geometry.advance(Duration::from_millis(8), SpringConfig::default(), true); + assert!(!geometry.presentation_changed); + return; + } + } + panic!("zoom failed to settle"); + } + #[test] fn scalar_spring_retargets_from_current_motion() { let mut value = AnimatedValue::new(0.0); diff --git a/docs/animation-validation.md b/docs/animation-validation.md new file mode 100644 index 0000000..76e9b3e --- /dev/null +++ b/docs/animation-validation.md @@ -0,0 +1,25 @@ +# Animation regression checks + +Run `cargo test -p ferese-animation -p ferese --offline` for transition, +resize pacing, reduced motion, stacking, and refresh-rate regression tests. + +For visual validation, use a disposable Ferese session with three tiled windows +containing text and at least one floating window. Test both the nested and DRM +backends. Record the session at its native refresh rate when comparing builds; +single screenshots cannot establish smoothness. + +| Scenario | Expected result | +| --- | --- | +| Super+F on the middle column, then exit | Neighbours remain behind the expanding window. The shrinking window stays above them until the transition finishes. | +| Toggle Super+F repeatedly at 40–80 ms intervals | Every reversal starts at the presented frame. Corners, border and shadow follow the same zoom progress. The final state matches the last input. | +| Cycle width, then immediately Super+F | The old column-width spring cannot override fullscreen width. | +| Exit fullscreen and immediately resize or scroll | The current frame is retained when the target changes; scrolling resumes without a position jump. | +| Drag a floating window; resize from its top-left corner | The window follows the pointer directly. The opposite edge stays anchored. Delayed client commits do not move or raise it. | +| Zoom a slow-redrawing client | Intermediate resize requests wait for commits or timeout. The final size is always requested and the animation keeps moving. | +| Disable borders and shadows, then zoom | Rounded corner pixels repaint through the final frame; no corner remnants remain. | +| Repeat on 60 Hz and 144 Hz outputs; move focus between them | Similar elapsed-time motion with no double advancement. Check frame timing on actual DRM hardware. | +| Enable reduced motion | Geometry and decorations reach their final values together. | + +The zoom resize policy asks responsive clients to redraw near the current visual +size. A late client buffer is still stretched as a fallback; this is not a buffer +snapshot or a guarantee of perfectly sharp text on every intermediate frame.