diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 09261fe..0957855 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -81,6 +81,31 @@ use crate::{ window_rules::{WindowRule, resolve as resolve_window_rules}, }; +const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140); + +#[derive(Clone, Copy, Debug, Default)] +struct ClosingAnimation { + progress: f64, + close_sent: bool, +} + +impl ClosingAnimation { + fn advance(&mut self, delta: Duration) -> bool { + if self.close_sent { + return false; + } + + self.progress = + (self.progress + delta.as_secs_f64() / CLOSE_ANIMATION_DURATION.as_secs_f64()).min(1.0); + if self.progress < 1.0 { + return false; + } + + self.close_sent = true; + true + } +} + pub struct Ferese { pub start_time: Instant, pub socket_name: OsString, @@ -96,6 +121,7 @@ pub struct Ferese { pub(crate) window_borders: HashMap, pub(crate) window_shadows: HashMap, pub(crate) rounded_clip_programs: HashMap, + closing_windows: HashMap, viewport_animations: HashMap, scrolling_world_x: HashMap, pub focused_window: Option, @@ -401,6 +427,7 @@ impl Ferese { window_borders: HashMap::new(), window_shadows: HashMap::new(), rounded_clip_programs: HashMap::new(), + closing_windows: HashMap::new(), viewport_animations: HashMap::new(), scrolling_world_x: HashMap::new(), focused_window: None, @@ -787,6 +814,7 @@ impl Ferese { self.window_geometry.remove(&id); self.window_borders.remove(&id); self.window_shadows.remove(&id); + self.closing_windows.remove(&id); self.window_rules_applied.remove(&id); self.scrolling_world_x.remove(&id); if let Err(error) = self.workspaces.remove_window(id) { @@ -1045,6 +1073,18 @@ impl Ferese { .collect::>(); let mut active_animation = false; + let mut ready_to_close = Vec::new(); + for (id, animation) in &mut self.closing_windows { + if animation.advance(delta) { + ready_to_close.push(*id); + } else if !animation.close_sent { + active_animation = true; + } + } + for id in ready_to_close { + self.send_window_close(id); + } + for (workspace, viewport) in &mut self.viewport_animations { if !visible_workspaces.contains(workspace) { continue; @@ -1352,15 +1392,33 @@ impl Ferese { self.relayout(); } - pub fn close_focused_window(&self) { + pub fn close_focused_window(&mut self) { let Some(focused) = self.focused_window else { return; }; - let Some(toplevel) = self - .window_ids - .iter() - .find_map(|(window, id)| (*id == focused).then(|| window.toplevel()).flatten()) - else { + + if !self.animations_enabled { + self.send_window_close(focused); + return; + } + + self.closing_windows.entry(focused).or_default(); + crate::backends::direct::render_all(self); + } + + pub(crate) fn closing_visual(&self, id: WindowId) -> (f64, f32) { + let Some(animation) = self.closing_windows.get(&id) else { + return (1.0, 1.0); + }; + let eased = smoothstep(animation.progress); + + (1.0 - eased * 0.02, (1.0 - eased) as f32) + } + + fn send_window_close(&self, id: WindowId) { + let Some(toplevel) = self.window_ids.iter().find_map(|(window, window_id)| { + (*window_id == id).then(|| window.toplevel()).flatten() + }) else { return; }; @@ -1591,6 +1649,12 @@ fn centered_floating_rect(bounds: Rect) -> Rect { ) } +fn smoothstep(progress: f64) -> f64 { + let progress = progress.clamp(0.0, 1.0); + + progress * progress * (3.0 - 2.0 * progress) +} + fn centered_transient_rect(parent: Rect) -> Rect { let width = (parent.width * 0.75).clamp(1.0, 640.0); let height = (parent.height * 0.75).clamp(1.0, 480.0); @@ -1633,4 +1697,22 @@ mod tests { assert_eq!(transient, Rect::new(280.0, 210.0, 640.0, 480.0)); } + + #[test] + fn close_easing_is_bounded_and_symmetric() { + assert_eq!(smoothstep(-1.0), 0.0); + assert_eq!(smoothstep(0.5), 0.5); + assert_eq!(smoothstep(2.0), 1.0); + } + + #[test] + fn close_animation_delays_the_protocol_close_until_it_finishes() { + let mut animation = ClosingAnimation::default(); + + assert!(!animation.advance(Duration::from_millis(70))); + assert_eq!(animation.progress, 0.5); + assert!(animation.advance(Duration::from_millis(70))); + assert!(animation.close_sent); + assert!(!animation.advance(Duration::from_secs(1))); + } } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 6a63ef5..ed7d96a 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -547,17 +547,19 @@ fn output_elements( .filter_map(|window| { let id = *state.window_ids.get(window)?; let visual = state.window_geometry.get(&id)?.visual.current; + let (close_scale, close_alpha) = state.closing_visual(id); + let visual = scaled_visual_rect(visual, close_scale); let fullscreen = state .workspaces .workspace_for_window(id) .and_then(|workspace| state.workspaces.workspace(workspace)) .is_some_and(|workspace| workspace.fullscreen == Some(id)); - Some((window.clone(), id, visual, fullscreen)) + Some((window.clone(), id, visual, fullscreen, close_alpha)) }) .collect::>(); - for (window, id, visual, fullscreen) in windows { + for (window, id, visual, fullscreen, close_alpha) in windows { let constrain = Rectangle::::new( ( (visual.x - f64::from(output_geometry.loc.x)).round() as i32, @@ -588,6 +590,7 @@ fn output_elements( } else { state.theme_settings.border_color.0 }; + let border_color = color_with_alpha(border_color, close_alpha); if let Some(border) = window_border_element( state, @@ -612,7 +615,7 @@ fn output_elements( window_radius, shadow_offset_y, shadow_blur, - shadow_opacity, + shadow_opacity * f64::from(close_alpha), shadow_color, output, &programs, @@ -623,6 +626,7 @@ fn output_elements( constrain, scale, window_radius, + close_alpha, output, programs.texture.clone(), )); @@ -886,6 +890,7 @@ fn rounded_window_elements( constrain: Rectangle, scale: f64, requested_radius: f64, + alpha: f32, output: &Output, program: GlesTexProgram, ) -> Vec { @@ -921,7 +926,7 @@ fn rounded_window_elements( popup.wl_surface(), location + offset, scale, - 1.0, + alpha, RenderElementKind::Unspecified, ) .into_iter() @@ -937,7 +942,7 @@ fn rounded_window_elements( surface, location, scale, - 1.0, + alpha, RenderElementKind::Unspecified, ) .into_iter() @@ -965,6 +970,24 @@ fn rounded_window_elements( .collect() } +fn scaled_visual_rect(rect: ferese_layout::Rect, scale: f64) -> ferese_layout::Rect { + let scale = scale.clamp(0.0, 1.0); + let width = rect.width * scale; + let height = rect.height * scale; + + ferese_layout::Rect::new( + rect.x + (rect.width - width) / 2.0, + rect.y + (rect.height - height) / 2.0, + width, + height, + ) +} + +fn color_with_alpha(mut color: [f32; 4], alpha: f32) -> [f32; 4] { + color[3] *= alpha; + color +} + fn framebuffer_clip_rect( geometry: Rectangle, output_size: smithay::utils::Size, @@ -1148,7 +1171,10 @@ fn normalized_scale(scale: f64) -> f64 { mod tests { use smithay::utils::{Logical, Physical, Rectangle, Transform}; - use super::{framebuffer_clip_rect, normalized_scale, shadow_bounds}; + use super::{ + color_with_alpha, framebuffer_clip_rect, normalized_scale, scaled_visual_rect, + shadow_bounds, + }; #[test] fn accepts_positive_finite_scale() { @@ -1199,4 +1225,23 @@ mod tests { Rectangle::new((80, 54).into(), (440, 340).into()) ); } + + #[test] + fn close_transform_scales_about_the_window_center() { + let rect = ferese_layout::Rect::new(100.0, 50.0, 400.0, 300.0); + + assert_eq!( + scaled_visual_rect(rect, 0.98), + ferese_layout::Rect::new(104.0, 53.0, 392.0, 294.0) + ); + assert_eq!(scaled_visual_rect(rect, 1.0), rect); + } + + #[test] + fn close_opacity_only_changes_the_alpha_channel() { + assert_eq!( + color_with_alpha([0.2, 0.4, 0.6, 0.8], 0.5), + [0.2, 0.4, 0.6, 0.4] + ); + } }