diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index aea6ab98..0cb05965 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -12,8 +12,10 @@ be handled deliberately. - Pointer drag and resize initiated by the mouse or tablet do not animate. - Linear animations restart only for windows whose destination changes. Other in-flight windows keep their existing timelines. -- Spawn-in uses scale and position. Spawn-out requires retained visual content - and is deferred until the freezing layer exists. +- 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. - 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 @@ -80,8 +82,8 @@ Implementation shape: Initial scope: - Tiled reflow animation. -- Floating command-driven moves, tile-to-float, float-to-tile, and spawn-in are - deferred until after tiled reflow is validated. +- Floating command-driven moves, tile-to-float, and float-to-tile are deferred + until after tiled reflow and spawn-in are validated. - Cross-output and cross-scale movements snap for now. - Linear mode may overlap windows during swaps. That is expected for the classic interpolation mode; no-overlap is Phase 3. @@ -89,7 +91,6 @@ Initial scope: 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. -- No content freezing. - No multiphase no-overlap planner. Tests: @@ -107,6 +108,8 @@ Goal: freeze visual contents during movement and enable spawn-out. Initial retained-record implementation status: - Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. +- Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees + for both tiled windows and floating child contents. - 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 f0daa804..45930733 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -17,6 +17,8 @@ use { }; const DEFAULT_DURATION_MS: u32 = 160; +const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4; +const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum AnimationCurve { @@ -224,6 +226,26 @@ impl AnimationState { true } + pub fn set_spawn_in( + &self, + node_id: NodeId, + target: Rect, + retained: Option>, + now_nsec: u64, + duration_ms: u32, + ) -> bool { + let start = spawn_in_start_rect(target); + self.set_target( + node_id, + start, + target, + retained, + now_nsec, + duration_ms, + AnimationCurve::Linear, + ) + } + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { let windows = self.windows.borrow(); match windows.get(&node_id) { @@ -350,6 +372,23 @@ impl LatchListener for AnimationTick { } } +pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { + fn scaled_dimension(value: i32) -> i32 { + let scaled = (value as i64 * SPAWN_IN_INITIAL_SCALE_NUMERATOR as i64 + / SPAWN_IN_INITIAL_SCALE_DENOMINATOR as i64) as i32; + scaled.clamp(1, value.max(1)) + } + + let width = scaled_dimension(target.width()); + let height = scaled_dimension(target.height()); + Rect::new_sized_saturating( + target.x1() + (target.width() - width) / 2, + target.y1() + (target.height() - height) / 2, + width, + height, + ) +} + 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 @@ -437,4 +476,25 @@ mod tests { Rect::new_sized_saturating(125, 0, 100, 100) ); } + + #[test] + fn spawn_in_start_rect_is_centered_and_non_empty() { + let target = Rect::new_sized_saturating(10, 20, 100, 50); + assert_eq!( + spawn_in_start_rect(target), + Rect::new_sized_saturating(20, 25, 80, 40) + ); + } + + #[test] + fn spawn_in_uses_linear_curve() { + let state = AnimationState::default(); + let id = NodeId(1); + let target = Rect::new_sized_saturating(10, 20, 100, 50); + assert!(state.set_spawn_in(id, target, None, 0, 160)); + assert_eq!( + state.visual_rect(id, target, 80_000_000), + Rect::new_sized_saturating(15, 23, 90, 45) + ); + } } diff --git a/src/renderer.rs b/src/renderer.rs index 5a891ac8..f8cfe80e 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -15,7 +15,7 @@ use { state::State, theme::{Color, CornerRadius}, tree::{ - ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, + ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData, ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, @@ -207,8 +207,13 @@ impl Renderer<'_> { if stacked.node_visible() { self.base.sync(); let pos = stacked.node_absolute_position(); - if pos.intersects(&opos) { - let (x, y) = opos.translate(pos.x1(), pos.y1()); + let visual = self.state.animations.visual_rect( + stacked.node_id(), + pos, + self.state.now_nsec(), + ); + if visual.intersects(&opos) { + let (x, y) = opos.translate(visual.x1(), visual.y1()); stacked.node_render(self, x, y, None); } } @@ -983,6 +988,10 @@ impl Renderer<'_> { _ => return, }; let pos = floating.position.get(); + let visual = + self.state + .animations + .visual_rect(floating.node_id(), pos, self.state.now_nsec()); let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let bc = if floating.active.get() { @@ -991,16 +1000,26 @@ impl Renderer<'_> { theme.colors.border.get() }; let cr = theme.corner_radius.get(); - let outer = Rect::new_sized_saturating(0, 0, pos.width(), pos.height()); + let outer = Rect::new_sized_saturating(0, 0, visual.width(), visual.height()); self.render_rounded_frame(outer, &bc, cr, bw, x, y); - let body = - Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw); + let body = Rect::new_sized_saturating( + x + bw, + y + bw, + visual.width() - 2 * bw, + visual.height() - 2 * bw, + ); let scissor_body = self.base.scale_rect(body); + self.stretch = if pos.width() != visual.width() || pos.height() != visual.height() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; if !cr.is_zero() { let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); self.corner_radius = Some(inner_cr); } - child.node_render(self, body.x1(), body.y1(), Some(&scissor_body)); + self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body)); + self.stretch = None; self.corner_radius = None; } diff --git a/src/state.rs b/src/state.rs index ddf69fd7..8bb78826 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ use { allocator::BufferObject, animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, + spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -1516,6 +1517,33 @@ impl State { } } + pub fn queue_spawn_in_animation( + self: &Rc, + node_id: NodeId, + target: Rect, + retained: Option>, + ) { + if !self.animations.enabled.get() || target.is_empty() { + return; + } + let start = spawn_in_start_rect(target); + let now = self.now_nsec(); + let started = self.animations.set_spawn_in( + node_id, + target, + retained, + now, + self.animations.duration_ms.get(), + ); + if started { + self.damage(expand_damage_rect( + start.union(target), + 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/float.rs b/src/tree/float.rs index dc0b44f4..747c275e 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -153,6 +153,14 @@ impl FloatNode { _ => return, }; let pos = self.position.get(); + let spawn_in_pending = { + let data = child.tl_data(); + data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get() + }; + if spawn_in_pending && self.visible.get() { + self.state + .queue_spawn_in_animation(self.id.into(), pos, None); + } let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let cpos = Rect::new_sized_saturating( diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index f4678729..499fedeb 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -118,6 +118,7 @@ impl ToplevelNode for T { let parent_was_none = data.parent.set(Some(parent.clone())).is_none(); if parent_was_none { data.mapped_during_iteration.set(data.state.eng.iteration()); + data.spawn_in_pending.set(data.kind.is_app_window()); data.property_changed(TL_CHANGED_NEW); } let was_floating = data.parent_is_float.get(); @@ -185,6 +186,7 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let spawn_in_pending = data.spawn_in_pending.get(); let parent_is_mono = data .parent .get() @@ -205,6 +207,22 @@ impl ToplevelNode for T { self.tl_animation_snapshot(), ); } + if spawn_in_pending + && !rect.is_empty() + && data.visible.get() + && !data.is_fullscreen.get() + && data.kind.is_app_window() + && !self.node_is_container() + { + data.state.clone().queue_spawn_in_animation( + data.node_id, + *rect, + self.tl_animation_snapshot(), + ); + } + if spawn_in_pending && !rect.is_empty() { + data.spawn_in_pending.set(false); + } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { sc.schedule_realloc_or_reconfigure(); @@ -403,6 +421,13 @@ impl ToplevelType { ToplevelType::XWindow { .. } => window::X_WINDOW, } } + + pub fn is_app_window(&self) -> bool { + matches!( + self, + ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_) + ) + } } pub struct ToplevelData { @@ -425,6 +450,7 @@ pub struct ToplevelData { pub title: RefCell, pub parent: CloneCell>>, pub mapped_during_iteration: Cell, + pub spawn_in_pending: Cell, pub pos: Cell, pub desired_extents: Cell, pub seat_state: NodeSeatState, @@ -488,6 +514,7 @@ impl ToplevelData { title: RefCell::new(title), parent: Default::default(), mapped_during_iteration: Cell::new(0), + spawn_in_pending: Cell::new(false), pos: Default::default(), desired_extents: Default::default(), seat_state: Default::default(),