From 13722429b42a422fc2a641c403f69530d7066c63 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:37:00 +1000 Subject: [PATCH] Run planned multiphase layout animations --- src/animation.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++-- src/state.rs | 92 ++++++++++++++++++++++- 2 files changed, 274 insertions(+), 9 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index 847b8f6d..fa7a58a7 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -131,6 +131,7 @@ pub struct AnimationState { pub duration_ms: Cell, pub curve: Cell, windows: RefCell>, + phased: RefCell>, exits: RefCell>, tick: CloneCell>>, } @@ -263,6 +264,7 @@ impl Default for AnimationState { duration_ms: Cell::new(DEFAULT_DURATION_MS), curve: Cell::new(AnimationCurve::from_config(3)), windows: Default::default(), + phased: Default::default(), exits: Default::default(), tick: Default::default(), } @@ -272,6 +274,7 @@ impl Default for AnimationState { impl AnimationState { pub fn clear(&self) { self.windows.borrow_mut().clear(); + self.phased.borrow_mut().clear(); self.exits.borrow_mut().clear(); if let Some(tick) = self.tick.take() { tick.detach(); @@ -290,20 +293,39 @@ impl AnimationState { ) -> bool { if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 { self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); return false; } let duration_nsec = duration_ms as u64 * 1_000_000; - let mut windows = self.windows.borrow_mut(); - let (from, retained) = match windows.get(&node_id) { - Some(anim) if anim.to == new => return false, - Some(anim) => (anim.rect_at(now_nsec), anim.retained.clone().or(retained)), - None => (old, retained), - }; + let mut from = old; + let mut retained = retained; + { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) { + if anim.final_rect == new { + return false; + } + from = anim.rect_at(now_nsec); + retained = anim.retained.clone().or(retained); + } + } + { + let windows = self.windows.borrow(); + if let Some(anim) = windows.get(&node_id) { + if anim.to == new { + return false; + } + from = anim.rect_at(now_nsec); + retained = anim.retained.clone().or(retained); + } + } if from == new { - windows.remove(&node_id); + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); return false; } - windows.insert( + self.phased.borrow_mut().remove(&node_id); + self.windows.borrow_mut().insert( node_id, WindowAnimation { from, @@ -318,6 +340,47 @@ impl AnimationState { true } + pub fn set_phased_target( + &self, + node_id: NodeId, + phases: Vec<(Rect, Rect)>, + retained: Option>, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if phases.is_empty() || duration_ms == 0 { + return false; + } + let Some((from, _)) = phases.first().copied() else { + return false; + }; + let Some((_, final_rect)) = phases.last().copied() else { + return false; + }; + if from.is_empty() || final_rect.is_empty() || from == final_rect { + return false; + } + let segments: Vec<_> = phases + .into_iter() + .map(|(from, to)| PhasedSegment { from, to }) + .collect(); + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().insert( + node_id, + PhasedWindowAnimation { + segments, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + curve, + last_damage: from, + final_rect, + retained, + }, + ); + true + } + pub fn set_spawn_in( &self, node_id: NodeId, @@ -375,6 +438,13 @@ impl AnimationState { } pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.rect_at(now_nsec); + } + drop(phased); let windows = self.windows.borrow(); match windows.get(&node_id) { Some(anim) if !anim.done(now_nsec) => anim.rect_at(now_nsec), @@ -387,6 +457,13 @@ impl AnimationState { node_id: NodeId, now_nsec: u64, ) -> Option> { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.retained.clone(); + } + drop(phased); let windows = self.windows.borrow(); match windows.get(&node_id) { Some(anim) if !anim.done(now_nsec) => anim.retained.clone(), @@ -427,6 +504,18 @@ impl AnimationState { any_active |= active; active }); + self.phased.borrow_mut().retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.final_rect); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + 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); @@ -485,6 +574,45 @@ impl WindowAnimation { } } +struct PhasedWindowAnimation { + segments: Vec, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, + final_rect: Rect, + retained: Option>, +} + +struct PhasedSegment { + from: Rect, + to: Rect, +} + +impl PhasedWindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + let total_duration = self + .duration_nsec + .saturating_mul(self.segments.len() as u64); + now_nsec.saturating_sub(self.start_nsec) >= total_duration + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.final_rect; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + let Some(segment) = self.segments.get(phase) else { + return self.final_rect; + }; + let phase_elapsed = elapsed % self.duration_nsec; + let t = (phase_elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(segment.from, segment.to, t) + } +} + struct ExitAnimation { from: Rect, to: Rect, @@ -722,6 +850,53 @@ mod tests { assert!(state.exit_frames(160_000_000).is_empty()); } + #[test] + fn phased_animation_uses_full_duration_per_phase() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + assert_eq!(state.visual_rect(id, c, 0), a); + assert_eq!(state.visual_rect(id, c, 50_000_000), lerp_rect(a, b, 0.5)); + assert_eq!(state.visual_rect(id, c, 100_000_000), b); + assert_eq!(state.visual_rect(id, c, 150_000_000), lerp_rect(b, c, 0.5)); + assert_eq!(state.visual_rect(id, c, 200_000_000), c); + } + + #[test] + fn linear_retarget_interrupts_phased_animation_from_current_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + let d = Rect::new_sized_saturating(100, 100, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + let current = lerp_rect(a, b, 0.5); + assert!(state.set_target(id, a, d, None, 50_000_000, 100, AnimationCurve::Linear)); + assert_eq!(state.visual_rect(id, d, 50_000_000), current); + assert_eq!( + state.visual_rect(id, d, 100_000_000), + lerp_rect(current, d, 0.5) + ); + } + #[test] fn unchanged_target_does_not_restart() { let state = AnimationState::default(); diff --git a/src/state.rs b/src/state.rs index 303a63c5..4273ac08 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,7 +4,9 @@ use { allocator::BufferObject, animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, - expand_damage_rect, spawn_in_start_rect, + expand_damage_rect, + multiphase::{MultiphaseRequest, MultiphaseWindow, plan_no_overlap}, + spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -157,6 +159,7 @@ use { uapi::{OwnedFd, c}, }; +#[derive(Clone)] pub(crate) struct LayoutAnimationCandidate { node_id: NodeId, old: Rect, @@ -1583,11 +1586,98 @@ impl State { return; }; let now = self.now_nsec(); + if self.start_multiphase_layout_animation(&candidates, now) { + return; + } for candidate in candidates { self.start_layout_animation_candidate(candidate, now); } } + fn start_multiphase_layout_animation( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + now_nsec: u64, + ) -> bool { + if candidates.len() < 2 { + return false; + } + let windows: Vec<_> = candidates + .iter() + .map(|candidate| MultiphaseWindow { + node_id: candidate.node_id, + from: self + .animations + .visual_rect(candidate.node_id, candidate.old, now_nsec), + to: candidate.new, + }) + .collect(); + let Some(first) = windows.first() else { + return false; + }; + let mut bounds = first.from.union(first.to); + for window in &windows[1..] { + bounds = bounds.union(window.from).union(window.to); + } + let Ok(plan) = plan_no_overlap(&MultiphaseRequest { bounds, windows }) else { + return false; + }; + if plan.phases.is_empty() { + return false; + } + let mut entries = vec![]; + for candidate in candidates { + let mut current = + self.animations + .visual_rect(candidate.node_id, candidate.old, now_nsec); + let mut damage = current.union(candidate.new); + let mut phases = vec![]; + for phase in &plan.phases { + match phase + .steps + .iter() + .find(|step| step.node_id == candidate.node_id) + { + Some(step) => { + phases.push((step.from, step.to)); + damage = damage.union(step.from).union(step.to); + current = step.to; + } + None => phases.push((current, current)), + } + } + if current != candidate.new { + return false; + } + let retained = self + .animations + .retained_snapshot(candidate.node_id, now_nsec) + .or_else(|| candidate.retained.clone()); + entries.push((candidate.clone(), phases, damage, retained)); + } + let mut started_any = false; + for (candidate, phases, damage, retained) in entries { + if self.animations.set_phased_target( + candidate.node_id, + phases, + retained, + now_nsec, + self.animations.duration_ms.get(), + candidate.curve, + ) { + started_any = true; + self.damage(expand_damage_rect( + damage, + self.theme.sizes.border_width.get().max(0), + )); + } + } + if started_any { + self.ensure_animation_tick(); + } + started_any + } + pub fn queue_spawn_in_animation( self: &Rc, node_id: NodeId,