diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index ad5bd329..74f6564c 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -14,8 +14,9 @@ be handled deliberately. in-flight windows keep their existing timelines. - Spawn-in uses scale and position for newly mapped tiled and floating app windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do - not use this path. Spawn-out requires retained visual content after the live - node is gone and remains deferred. + not use this path. Spawn-out uses retained visual content after the live node + is gone, when a stable retained surface tree can be captured before unmap or + destroy. - Command-driven tile-to-float and float-to-tile transitions may animate. Protocol drag/drop paths do not. - The no-overlap multiphase system is a separate phase after the linear path is @@ -90,7 +91,8 @@ Initial scope: - Live client buffers are rendered in Phase 1. Retained content freezing is deferred, but animated windows must still be clipped to their presentation bounds and must preserve the existing stretch behavior for undersized contents. -- No spawn-out. +- Spawn-out is retained-content-only. If the surface cannot be retained safely + the window snaps out instead of animating an empty frame. - No multiphase no-overlap planner. Tests: @@ -116,6 +118,9 @@ Initial retained-record implementation status: for both tiled windows and floating child contents. - Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child contents while the presentation geometry changes. +- Spawn-out captures retained app-window contents before XDG/Xwayland unmap or + destroy, then renders a detached shrinking presentation record until the + animation completes. - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the existing buffer release/sync path remains authoritative. - Single-pixel buffers can be retained as color records. diff --git a/src/animation.rs b/src/animation.rs index 45930733..199e142b 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -57,6 +57,7 @@ pub struct AnimationState { pub duration_ms: Cell, pub curve: Cell, windows: RefCell>, + exits: RefCell>, tick: CloneCell>>, } @@ -92,6 +93,21 @@ pub enum RetainedContent { }, } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum RetainedExitLayer { + Tiled, + Floating, +} + +pub struct RetainedExitFrame { + pub rect: Rect, + pub retained: Rc, + pub frame_inset: i32, + pub source_body_size: (i32, i32), + pub active: bool, + pub layer: RetainedExitLayer, +} + impl RetainedToplevel { pub fn capture_surface(surface: &WlSurface, offset: (i32, i32)) -> Option> { Some(Rc::new(Self { @@ -173,6 +189,7 @@ impl Default for AnimationState { duration_ms: Cell::new(DEFAULT_DURATION_MS), curve: Cell::new(AnimationCurve::EaseOut), windows: Default::default(), + exits: Default::default(), tick: Default::default(), } } @@ -181,6 +198,7 @@ impl Default for AnimationState { impl AnimationState { pub fn clear(&self) { self.windows.borrow_mut().clear(); + self.exits.borrow_mut().clear(); if let Some(tick) = self.tick.take() { tick.detach(); } @@ -246,6 +264,42 @@ impl AnimationState { ) } + pub fn set_spawn_out( + &self, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + now_nsec: u64, + duration_ms: u32, + ) -> bool { + if from.is_empty() || duration_ms == 0 { + return false; + } + let to = spawn_in_start_rect(from); + if to == from || to.is_empty() { + return false; + } + let source_body_size = body_size_for_frame(from, frame_inset); + if source_body_size.0 <= 0 || source_body_size.1 <= 0 { + return false; + } + self.exits.borrow_mut().push(ExitAnimation { + from, + to, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + last_damage: from, + retained, + frame_inset, + source_body_size, + active, + layer, + }); + true + } + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { let windows = self.windows.borrow(); match windows.get(&node_id) { @@ -266,6 +320,22 @@ impl AnimationState { } } + pub fn exit_frames(&self, now_nsec: u64) -> Vec { + self.exits + .borrow() + .iter() + .filter(|exit| !exit.done(now_nsec)) + .map(|exit| RetainedExitFrame { + rect: exit.rect_at(now_nsec), + retained: exit.retained.clone(), + frame_inset: exit.frame_inset, + source_body_size: exit.source_body_size, + active: exit.active, + layer: exit.layer, + }) + .collect() + } + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { let mut damages = vec![]; let mut any_active = false; @@ -283,6 +353,18 @@ impl AnimationState { any_active |= active; active }); + self.exits.borrow_mut().retain_mut(|exit| { + let current = exit.rect_at(now_nsec); + let damage = exit.last_damage.union(current).union(exit.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + exit.last_damage = current; + let active = !exit.done(now_nsec); + any_active |= active; + active + }); } for damage in damages { state.damage(damage); @@ -329,6 +411,34 @@ impl WindowAnimation { } } +struct ExitAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + last_damage: Rect, + retained: Rc, + frame_inset: i32, + source_body_size: (i32, i32), + active: bool, + layer: RetainedExitLayer, +} + +impl ExitAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + lerp_rect(self.from, self.to, t) + } +} + pub struct AnimationTick { state: Weak, slf: Weak, @@ -389,6 +499,13 @@ pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { ) } +fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) { + ( + rect.width().saturating_sub(2 * frame_inset), + rect.height().saturating_sub(2 * frame_inset), + ) +} + fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect { fn lerp(from: i32, to: i32, t: f64) -> i32 { (from as f64 + (to as f64 - from as f64) * t).round() as i32 diff --git a/src/ifs/wl_surface/x_surface.rs b/src/ifs/wl_surface/x_surface.rs index 1c3e295c..4f6db63c 100644 --- a/src/ifs/wl_surface/x_surface.rs +++ b/src/ifs/wl_surface/x_surface.rs @@ -1,7 +1,7 @@ use { crate::{ ifs::wl_surface::{ - SurfaceExt, WlSurface, WlSurfaceError, + PendingState, SurfaceExt, WlSurface, WlSurfaceError, x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow}, }, leaks::Tracker, @@ -30,6 +30,22 @@ impl SurfaceExt for XSurface { win.node_layer() } + fn before_apply_commit( + self: Rc, + pending: &mut PendingState, + ) -> Result<(), WlSurfaceError> { + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(xwindow) = self.xwindow.get() + { + xwindow.queue_spawn_out(); + } + Ok(()) + } + fn after_apply_commit(self: Rc) { if let Some(xwindow) = self.xwindow.get() { xwindow.map_status_changed(); @@ -45,6 +61,7 @@ impl SurfaceExt for XSurface { } self.surface.unset_ext(); if let Some(xwindow) = self.xwindow.take() { + xwindow.queue_spawn_out(); xwindow.tl_destroy(); xwindow.data.window.set(None); xwindow.data.surface_id.set(None); diff --git a/src/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index a4d3e88b..80ea8b1b 100644 --- a/src/ifs/wl_surface/x_surface/xwindow.rs +++ b/src/ifs/wl_surface/x_surface/xwindow.rs @@ -253,6 +253,11 @@ impl Xwindow { self.x.surface.buffer.is_some() && self.data.info.mapped.get() } + pub fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map_change(&self) -> Change { match (self.may_be_mapped(), self.is_mapped()) { (true, false) => Change::Map, @@ -275,6 +280,7 @@ impl Xwindow { match map_change { Change::None => return, Change::Unmap => { + self.queue_spawn_out(); self.data .info .pending_extents diff --git a/src/ifs/wl_surface/xdg_surface.rs b/src/ifs/wl_surface/xdg_surface.rs index 9b5130d7..ad87c951 100644 --- a/src/ifs/wl_surface/xdg_surface.rs +++ b/src/ifs/wl_surface/xdg_surface.rs @@ -226,6 +226,10 @@ pub trait XdgSurfaceExt: Debug { // nothing } + fn prepare_unmap(&self) { + // nothing + } + fn extents_changed(&self) { // nothing } @@ -664,6 +668,15 @@ impl SurfaceExt for XdgSurface { if let Some(serial) = pending.serial.take() { self.applied_serial.set(serial); } + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(ext) = self.ext.get() + { + ext.prepare_unmap(); + } Ok(()) } diff --git a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs index 740c7a50..6a7f395f 100644 --- a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs +++ b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs @@ -260,6 +260,7 @@ impl XdgToplevelRequestHandler for XdgToplevel { type Error = XdgToplevelError; fn destroy(&self, _req: Destroy, _slf: &Rc) -> Result<(), Self::Error> { + self.queue_spawn_out(); self.tl_destroy(); self.xdg.unset_ext(); { @@ -399,6 +400,11 @@ impl XdgToplevelRequestHandler for XdgToplevel { } impl XdgToplevel { + fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map( self: &Rc, parent: Option<&XdgToplevel>, @@ -824,6 +830,10 @@ impl XdgSurfaceExt for XdgToplevel { self.after_commit(None); } + fn prepare_unmap(&self) { + self.queue_spawn_out(); + } + fn extents_changed(&self) { self.toplevel_data.pos.set(self.xdg.extents.get()); self.tl_extents_changed(); diff --git a/src/renderer.rs b/src/renderer.rs index f8cfe80e..bb44e71d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,6 +1,9 @@ use { crate::{ - animation::{RetainedContent, RetainedSurface, RetainedToplevel}, + animation::{ + RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface, + RetainedToplevel, + }, cmm::cmm_render_intent::RenderIntent, gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect}, ifs::wl_surface::{ @@ -201,6 +204,9 @@ impl Renderer<'_> { self.render_workspace(&ws, x, y); } } + let now = self.state.now_nsec(); + let exit_frames = self.state.animations.exit_frames(now); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Tiled, &opos); macro_rules! render_stacked { ($stack:expr) => { for stacked in $stack.iter() { @@ -221,6 +227,7 @@ impl Renderer<'_> { }; } render_stacked!(self.state.root.stacked); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Floating, &opos); // Flush RoundedFillRect ops from container/float borders so they don't // sort after (and render on top of) layer-shell CopyTexture ops. self.base.sync(); @@ -504,6 +511,68 @@ impl Renderer<'_> { self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds); } + fn render_exit_frames( + &mut self, + frames: &[RetainedExitFrame], + layer: RetainedExitLayer, + output_rect: &Rect, + ) { + for frame in frames { + if frame.layer != layer || !frame.rect.intersects(output_rect) { + continue; + } + self.render_exit_frame(frame, output_rect); + } + } + + fn render_exit_frame(&mut self, frame: &RetainedExitFrame, output_rect: &Rect) { + let (x, y) = output_rect.translate(frame.rect.x1(), frame.rect.y1()); + let inset = frame.frame_inset; + if inset > 0 { + let color = if frame.active { + self.state.theme.colors.active_border.get() + } else { + self.state.theme.colors.border.get() + }; + self.render_rounded_frame( + Rect::new_sized_saturating(0, 0, frame.rect.width(), frame.rect.height()), + &color, + self.state.theme.corner_radius.get(), + inset, + x, + y, + ); + } + let body = Rect::new_sized_saturating( + x + inset, + y + inset, + frame.rect.width() - 2 * inset, + frame.rect.height() - 2 * inset, + ); + if body.is_empty() { + return; + } + let bounds = self.base.scale_rect(body); + self.stretch = if frame.source_body_size != body.size() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; + if inset > 0 && !self.state.theme.corner_radius.get().is_zero() { + let inner_cr = self.scale_corner_radius( + self.state + .theme + .corner_radius + .get() + .expanded_by(-(inset as f32)), + ); + self.corner_radius = Some(inner_cr); + } + self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds)); + self.stretch = None; + self.corner_radius = None; + } + fn render_retained_surface_scaled( &mut self, retained: &RetainedSurface, diff --git a/src/state.rs b/src/state.rs index a94c8a37..5ccb0883 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,8 +3,8 @@ use { acceptor::Acceptor, allocator::BufferObject, animation::{ - AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, - spawn_in_start_rect, + AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, + expand_damage_rect, spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -1572,6 +1572,36 @@ impl State { } } + pub fn queue_spawn_out_animation( + self: &Rc, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + ) { + if !self.animations.enabled.get() || from.is_empty() { + return; + } + let now = self.now_nsec(); + let started = self.animations.set_spawn_out( + from, + frame_inset, + retained, + active, + layer, + now, + self.animations.duration_ms.get(), + ); + if started { + self.damage(expand_damage_rect( + from, + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + pub fn set_animations_enabled(&self, enabled: bool) { if self.animations.enabled.replace(enabled) && !enabled { self.animations.clear(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index dc9927f9..41db95d1 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,6 +1,6 @@ use { crate::{ - animation::RetainedToplevel, + animation::{RetainedExitLayer, RetainedToplevel}, client::{Client, ClientId}, criteria::{ CritDestroyListener, CritMatcherId, @@ -988,6 +988,62 @@ impl ToplevelData { self.mapped_during_iteration.get() == self.state.eng.iteration() } + pub fn queue_spawn_out(&self, node: &dyn ToplevelNode, retained: Option>) { + if !self.kind.is_app_window() + || !self.visible.get() + || self.is_fullscreen.get() + || node.node_is_container() + { + return; + } + let Some(retained) = retained else { + return; + }; + let bw = self.state.theme.sizes.border_width.get().max(0); + let now = self.state.now_nsec(); + let (outer, frame_inset, layer) = if self.parent_is_float.get() { + let Some(float) = self.float.get() else { + return; + }; + ( + self.state + .animations + .visual_rect(float.node_id(), float.position.get(), now), + bw, + RetainedExitLayer::Floating, + ) + } else { + let body = + self.state + .animations + .visual_rect(self.node_id, node.node_absolute_position(), now); + if body.is_empty() { + return; + } + if self.state.theme.sizes.gap.get() != 0 { + ( + Rect::new_sized_saturating( + body.x1() - bw, + body.y1() - bw, + body.width() + 2 * bw, + body.height() + 2 * bw, + ), + bw, + RetainedExitLayer::Tiled, + ) + } else { + (body, 0, RetainedExitLayer::Tiled) + } + }; + self.state.clone().queue_spawn_out_animation( + outer, + frame_inset, + retained, + self.active(), + layer, + ); + } + pub fn set_content_type(&self, content_type: Option) { if self.content_type.replace(content_type) != content_type { self.property_changed(TL_CHANGED_CONTENT_TY);