fix: unify animated presentation and stacking

This commit is contained in:
Samuel Onoja committed 2026-09-25 04:57:39 +01:00
1 parent 83e36b0d2d
commit 6d19932a64
11 files changed
+481 -104

No files matched your search

+4 -9
View File
@@ -7,7 +7,7 @@ use smithay::{
PointerInnerHandle, RelativeMotionEvent, PointerInnerHandle, RelativeMotionEvent,
}, },
reexports::wayland_server::protocol::wl_surface::WlSurface, reexports::wayland_server::protocol::wl_surface::WlSurface,
utils::{Logical, Point}, utils::{Logical, Point, Size},
}; };
use crate::Ferese; use crate::Ferese;
@@ -16,6 +16,7 @@ pub struct MoveSurfaceGrab {
pub start_data: GrabStartData<Ferese>, pub start_data: GrabStartData<Ferese>,
pub window: Window, pub window: Window,
pub initial_location: Point<i32, Logical>, pub initial_location: Point<i32, Logical>,
pub initial_size: Size<i32, Logical>,
pub finished: bool, pub finished: bool,
} }
@@ -30,10 +31,7 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
handle.motion(data, None, event); handle.motion(data, None, event);
let location = self.initial_location.to_f64() + (event.location - self.start_data.location); let location = self.initial_location.to_f64() + (event.location - self.start_data.location);
let location = location.to_i32_round(); let location = location.to_i32_round();
let size = self.window.geometry().size; data.set_floating_window_geometry(&self.window, location, self.initial_size);
data.set_floating_window_geometry(&self.window, location, size);
data.space.map_element(self.window.clone(), location, true);
} }
fn relative_motion( fn relative_motion(
@@ -153,9 +151,6 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
return; return;
} }
let size = self.window.geometry().size; data.set_floating_window_geometry(&self.window, self.initial_location, self.initial_size);
data.set_floating_window_geometry(&self.window, self.initial_location, size);
data.space
.map_element(self.window.clone(), self.initial_location, true);
} }
} }
+5 -32
View File
@@ -1,7 +1,7 @@
use std::cell::RefCell; use std::cell::RefCell;
use smithay::{ use smithay::{
desktop::{Space, Window}, desktop::Window,
input::pointer::{ input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent,
@@ -275,8 +275,6 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
self.initial_rect.loc, self.initial_rect.loc,
self.initial_rect.size, 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<Window>, surface: &WlSurface) { pub fn handle_resize_commit(surface: &WlSurface) {
let Some(window) = space // The compositor's visual rect owns the anchored edge. A delayed client
.elements() // commit must not move the window or change its stacking order.
.find(|window| { ResizeState::with(surface, ResizeState::commit);
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);
}
} }
fn constrained_size( fn constrained_size(
+1 -1
View File
@@ -54,7 +54,7 @@ impl XdgActivationHandler for Ferese {
tracing::error!(%error, ?focused, "failed to update workspace focus"); tracing::error!(%error, ?focused, "failed to update workspace focus");
return; return;
} }
self.space.raise_element(&window, true); self.raise_window(&window, true);
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial()); keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial());
self.space.elements().for_each(|window| { self.space.elements().for_each(|window| {
+6 -6
View File
@@ -12,7 +12,7 @@ use smithay::{
Resource, Resource,
protocol::{wl_seat, wl_surface::WlSurface}, protocol::{wl_seat, wl_surface::WlSurface},
}, },
utils::{Rectangle, Serial}, utils::Serial,
wayland::{ wayland::{
compositor::with_states, compositor::with_states,
shell::xdg::decoration::XdgDecorationHandler, shell::xdg::decoration::XdgDecorationHandler,
@@ -136,7 +136,7 @@ impl XdgShellHandler for Ferese {
else { else {
return; return;
}; };
let Some(initial_location) = self.space.element_location(&window) else { let Some(initial_rect) = self.visual_rect_for_window(&window) else {
return; return;
}; };
if !self.is_floating_window(&window) { if !self.is_floating_window(&window) {
@@ -147,7 +147,8 @@ impl XdgShellHandler for Ferese {
MoveSurfaceGrab { MoveSurfaceGrab {
start_data, start_data,
window, window,
initial_location, initial_location: initial_rect.loc,
initial_size: initial_rect.size,
finished: false, finished: false,
}, },
serial, serial,
@@ -183,13 +184,12 @@ impl XdgShellHandler for Ferese {
else { else {
return; return;
}; };
let Some(location) = self.space.element_location(&window) else { let Some(rect) = self.visual_rect_for_window(&window) else {
return; return;
}; };
if !self.is_floating_window(&window) { if !self.is_floating_window(&window) {
return; return;
} }
let rect = Rectangle::new(location, window.geometry().size);
pointer.set_grab( pointer.set_grab(
self, self,
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect), 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<Window>, surface: &WlSurface) { pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surface: &WlSurface) {
handle_resize_commit(space, surface); handle_resize_commit(surface);
if let Some(window) = space if let Some(window) = space
.elements() .elements()
.find(|window| { .find(|window| {
+1 -1
View File
@@ -308,7 +308,7 @@ impl Ferese {
{ {
tracing::error!(%error, ?focused, "failed to update workspace focus"); tracing::error!(%error, ?focused, "failed to update workspace focus");
} }
self.space.raise_element(&window, true); self.raise_window(&window, true);
let surface = window let surface = window
.toplevel() .toplevel()
.expect("mapped window has a toplevel") .expect("mapped window has a toplevel")
+1
View File
@@ -8,6 +8,7 @@ mod handlers;
mod input; mod input;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod stacking;
mod state; mod state;
mod window_rules; mod window_rules;
mod winit; mod winit;
+48
View File
@@ -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<WindowId>,
}
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);
}
}
+86 -52
View File
@@ -45,7 +45,7 @@ use smithay::{
protocol::wl_surface::WlSurface, protocol::wl_surface::WlSurface,
}, },
}, },
utils::{Logical, Point, Size}, utils::{Logical, Point, Rectangle, Size},
wayland::{ wayland::{
alpha_modifier::AlphaModifierState, alpha_modifier::AlphaModifierState,
compositor::{CompositorClientState, CompositorState, with_states}, compositor::{CompositorClientState, CompositorState, with_states},
@@ -118,7 +118,7 @@ pub struct Ferese {
output_identity_ids: HashMap<String, OutputId>, output_identity_ids: HashMap<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub(crate) window_decoration_progress: HashMap<WindowId, AnimatedValue>, window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>, pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
@@ -429,7 +429,7 @@ impl Ferese {
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
window_decoration_progress: HashMap::new(), window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_shadows: HashMap::new(), window_shadows: HashMap::new(),
rounded_clip_programs: 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::<Vec<_>>();
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)> { pub fn visual_scale_for_window(&self, window: &Window) -> Option<(f64, f64)> {
let id = self.window_ids.get(window)?; let id = self.window_ids.get(window)?;
self.window_geometry.get(id)?.visual_scale() self.window_geometry.get(id)?.visual_scale()
} }
pub(crate) fn visual_rect_for_window(
&self,
window: &Window,
) -> Option<Rectangle<i32, Logical>> {
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) { pub fn add_tiled_window(&mut self, window: Window) {
let id = WindowId(self.next_window_id); let id = WindowId(self.next_window_id);
self.next_window_id += 1; self.next_window_id += 1;
@@ -661,6 +699,7 @@ impl Ferese {
} }
self.window_ids.insert(window.clone(), id); self.window_ids.insert(window.clone(), id);
self.window_stack.insert(id);
if focus_new_window { if focus_new_window {
self.focused_window = Some(id); self.focused_window = Some(id);
} }
@@ -695,6 +734,7 @@ impl Ferese {
} }
self.window_ids.insert(window.clone(), id); self.window_ids.insert(window.clone(), id);
self.window_stack.insert(id);
self.space.map_element(window, (0, 0), false); self.space.map_element(window, (0, 0), false);
self.relayout(); self.relayout();
} }
@@ -821,7 +861,7 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.window_decoration_progress.remove(&id); self.window_stack.remove(id);
self.window_borders.remove(&id); self.window_borders.remove(&id);
self.window_shadows.remove(&id); self.window_shadows.remove(&id);
self.closing_windows.remove(&id); self.closing_windows.remove(&id);
@@ -918,8 +958,6 @@ impl Ferese {
let rect = if workspace_fullscreen == Some(*id) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds fullscreen_bounds
} else if workspace_fullscreen.is_some() {
continue;
} else { } else {
match self.workspaces.placement(*id) { match self.workspaces.placement(*id) {
Some(WindowPlacement::Tiled) => { Some(WindowPlacement::Tiled) => {
@@ -973,30 +1011,9 @@ impl Ferese {
let now = self.start_time.elapsed(); let now = self.start_time.elapsed();
let mut scrolling_world_x = HashMap::new(); let mut scrolling_world_x = HashMap::new();
// map_element also raises windows. Keep relayout's HashMap iteration placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id));
// 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::<HashMap<_, _>>();
placements.sort_by_key(|(_, id, ..)| stacking_order.get(id).copied().unwrap_or(usize::MAX));
let mut fullscreen_transitions = Vec::new();
for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements { 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. // A viewport-coupled width must never override fullscreen/floating geometry.
if scrolling.is_none() { if scrolling.is_none() {
self.viewport_coupled_widths.remove(&id); self.viewport_coupled_widths.remove(&id);
@@ -1007,9 +1024,13 @@ impl Ferese {
.window_geometry .window_geometry
.entry(id) .entry(id)
.or_insert_with(|| WindowGeometry::new(rect, committed_size)); .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 { if !self.animations_enabled {
geometry.advance(Duration::ZERO, self.spring_config, false); 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 { if let Some((workspace, world_x, viewport_x)) = scrolling {
let restored_world_x = restored_scrolling_world_x( let restored_world_x = restored_scrolling_world_x(
@@ -1027,15 +1048,17 @@ impl Ferese {
if !self.animations_enabled { if !self.animations_enabled {
animated_world_x.snap(); animated_world_x.snap();
} }
if !geometry.is_zooming() {
geometry.visual.current.x = animated_world_x.current - viewport_x; geometry.visual.current.x = animated_world_x.current - viewport_x;
geometry.visual.velocity.x = 0.0; geometry.visual.velocity.x = animated_world_x.velocity;
}
scrolling_world_x.insert(id, (workspace, animated_world_x)); scrolling_world_x.insert(id, (workspace, animated_world_x));
let coupled = couple_width let coupled = !geometry.is_zooming()
|| self && (couple_width
.viewport_coupled_widths || self.viewport_coupled_widths.get(&id).is_some_and(
.get(&id) |(previous_workspace, _)| *previous_workspace == workspace,
.is_some_and(|(previous_workspace, _)| *previous_workspace == workspace); ));
if coupled { if coupled {
let width = self.viewport_coupled_widths.entry(id).or_insert_with(|| { let width = self.viewport_coupled_widths.entry(id).or_insert_with(|| {
(workspace, AnimatedValue::new(geometry.visual.current.width)) (workspace, AnimatedValue::new(geometry.visual.current.width))
@@ -1099,11 +1122,7 @@ impl Ferese {
} }
} }
self.scrolling_world_x = scrolling_world_x; self.scrolling_world_x = scrolling_world_x;
// Neighbours are remapped on fullscreen exit; the shrinking window must self.sync_window_stacking();
// remain above them. Subsequent animation ticks preserve this ordering.
for window in fullscreen_transitions {
self.space.raise_element(&window, false);
}
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
@@ -1175,16 +1194,10 @@ impl Ferese {
let mut settled_coupled_widths = Vec::new(); let mut settled_coupled_widths = Vec::new();
for (window, id) in windows { 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 { let Some(geometry) = self.window_geometry.get_mut(&id) else {
continue; continue;
}; };
let zooming = geometry.is_zooming();
let coupled_target = self let coupled_target = self
.viewport_coupled_widths .viewport_coupled_widths
@@ -1202,13 +1215,18 @@ impl Ferese {
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
&& let Some(viewport) = self.viewport_animations.get(workspace) && 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); active_animation |= world_x.advance(delta, self.spring_config);
} else { } else {
world_x.snap(); world_x.snap();
} }
if !zooming {
geometry.visual.current.x = world_x.current - viewport.current; geometry.visual.current.x = world_x.current - viewport.current;
geometry.visual.velocity.x = 0.0; geometry.visual.velocity.x = world_x.velocity - viewport.velocity;
}
} }
if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) { if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) {
let width_active = if self.animations_enabled { let width_active = if self.animations_enabled {
@@ -1233,6 +1251,14 @@ impl Ferese {
"client did not commit the final configured size within 500 ms" "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; let visual = geometry.visual.current;
self.space.map_element( self.space.map_element(
window, window,
@@ -1243,6 +1269,7 @@ impl Ferese {
for id in settled_coupled_widths { for id in settled_coupled_widths {
self.viewport_coupled_widths.remove(&id); self.viewport_coupled_widths.remove(&id);
} }
self.sync_window_stacking();
active_animation active_animation
} }
@@ -1302,7 +1329,7 @@ impl Ferese {
}; };
self.focused_window = Some(next); self.focused_window = Some(next);
self.space.raise_element(&window, true); self.raise_window(&window, true);
self.seat self.seat
.get_keyboard() .get_keyboard()
.expect("seat has a keyboard") .expect("seat has a keyboard")
@@ -1585,11 +1612,18 @@ impl Ferese {
.window_geometry .window_geometry
.entry(id) .entry(id)
.or_insert_with(|| WindowGeometry::new(rect, client_size(window))); .or_insert_with(|| WindowGeometry::new(rect, client_size(window)));
geometry.set_logical_target(rect, self.start_time.elapsed()); if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed())
geometry.visual.snap(); && 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.scrolling_world_x.remove(&id);
self.viewport_coupled_widths.remove(&id); self.viewport_coupled_widths.remove(&id);
self.space.map_element(window.clone(), location, false); self.space.map_element(window.clone(), location, false);
self.sync_window_stacking();
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
+17 -3
View File
@@ -264,6 +264,7 @@ struct RoundedSurfaceRenderElement {
program: GlesTexProgram, program: GlesTexProgram,
clip_rect: [f32; 4], clip_rect: [f32; 4],
radius: f32, radius: f32,
clip_changed: bool,
} }
impl Element for RoundedSurfaceRenderElement { impl Element for RoundedSurfaceRenderElement {
@@ -292,12 +293,19 @@ impl Element for RoundedSurfaceRenderElement {
scale: RenderScale<f64>, scale: RenderScale<f64>,
commit: Option<CommitCounter>, commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> { ) -> DamageSet<i32, Physical> {
if self.clip_changed {
return DamageSet::from_slice(&[Rectangle::from_size(self.inner.geometry(scale).size)]);
}
self.inner.damage_since(scale, commit) self.inner.damage_since(scale, commit)
} }
fn opaque_regions(&self, _scale: RenderScale<f64>) -> OpaqueRegions<i32, Physical> { fn opaque_regions(&self, scale: RenderScale<f64>) -> OpaqueRegions<i32, Physical> {
if self.radius == 0.0 {
self.inner.opaque_regions(scale)
} else {
OpaqueRegions::default() OpaqueRegions::default()
} }
}
fn alpha(&self) -> f32 { fn alpha(&self) -> f32 {
self.inner.alpha() self.inner.alpha()
@@ -575,9 +583,9 @@ fn output_elements(
let (close_scale, close_alpha) = state.closing_visual(id); let (close_scale, close_alpha) = state.closing_visual(id);
let visual = scaled_visual_rect(visual, close_scale); let visual = scaled_visual_rect(visual, close_scale);
let decoration_progress = state let decoration_progress = state
.window_decoration_progress .window_geometry
.get(&id) .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)) Some((window.clone(), id, visual, decoration_progress, close_alpha))
}) })
@@ -641,6 +649,10 @@ fn output_elements(
scale, scale,
window_radius, window_radius,
close_alpha, close_alpha,
state
.window_geometry
.get(&id)
.is_some_and(|geometry| geometry.presentation_changed),
output, output,
programs.texture.clone(), programs.texture.clone(),
)); ));
@@ -905,6 +917,7 @@ fn rounded_window_elements(
scale: f64, scale: f64,
requested_radius: f64, requested_radius: f64,
alpha: f32, alpha: f32,
clip_changed: bool,
output: &Output, output: &Output,
program: GlesTexProgram, program: GlesTexProgram,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
@@ -966,6 +979,7 @@ fn rounded_window_elements(
program: program.clone(), program: program.clone(),
clip_rect: clip, clip_rect: clip,
radius, radius,
clip_changed,
} }
.into() .into()
}), }),
+287
View File
@@ -272,6 +272,18 @@ pub struct WindowGeometry {
pub logical: Rect, pub logical: Rect,
pub visual: AnimatedRect, pub visual: AnimatedRect,
pub client: ClientGeometry, pub client: ClientGeometry,
pub decorations: f64,
pub presentation_changed: bool,
fullscreen: bool,
zoom: Option<ZoomTransition>,
}
/// 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 { impl WindowGeometry {
@@ -286,6 +298,10 @@ impl WindowGeometry {
committed_size, committed_size,
waiting_since: None, 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) self.client.request_size(size, now).then_some(size)
} }
pub fn set_presentation_target(
&mut self,
rect: Rect,
fullscreen: bool,
now: Duration,
) -> Option<ClientSize> {
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::<f64>();
if squared_length > 0.001 {
let projected =
distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / 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<ClientSize> {
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<ClientSize> {
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( pub fn advance(
&mut self, &mut self,
delta: Duration, delta: Duration,
config: SpringConfig, config: SpringConfig,
animations_enabled: bool, 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 { ) -> bool {
if !animations_enabled { if !animations_enabled {
self.visual.snap(); self.visual.snap();
self.decorations = if self.fullscreen { 0.0 } else { 1.0 };
self.zoom = None;
return false; 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) self.visual.advance(delta, config)
} }
@@ -407,6 +541,159 @@ fn finite_or(value: f64, fallback: f64) -> f64 {
mod tests { mod tests {
use super::*; 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] #[test]
fn scalar_spring_retargets_from_current_motion() { fn scalar_spring_retargets_from_current_motion() {
let mut value = AnimatedValue::new(0.0); let mut value = AnimatedValue::new(0.0);
+25
View File
@@ -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.