From 4ee2c324e12fea54f76d616dad6e50e3db7f35bd Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:46:10 +1000 Subject: [PATCH] Fallback layout animations by motion group --- docs/window-animations-plan.md | 3 ++ src/animation/multiphase.rs | 75 ++++++++++++++++++++++++++++++++++ src/state.rs | 64 +++++++++++++++++------------ 3 files changed, 115 insertions(+), 27 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index a4e39525..0252820c 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -221,6 +221,9 @@ Current pure planner status: orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. - Every produced plan is sampled for overlap at each phase before it is accepted. +- Live layout batches are partitioned by overlapping motion bounds, so unrelated + groups can still use multiphase animation when another group falls back to + linear motion. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 05c8617c..ffaea6d1 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -81,6 +81,33 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result Vec> { + let mut groups = vec![]; + let mut seen = vec![false; windows.len()]; + for start in 0..windows.len() { + if seen[start] { + continue; + } + seen[start] = true; + let mut group = vec![]; + let mut pending = vec![start]; + while let Some(idx) = pending.pop() { + group.push(idx); + let bounds = motion_bounds(windows[idx]); + for other in 0..windows.len() { + if seen[other] || !bounds.intersects(&motion_bounds(windows[other])) { + continue; + } + seen[other] = true; + pending.push(other); + } + } + group.sort_unstable(); + groups.push(group); + } + groups +} + fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> { if request.bounds.is_empty() { return Err(MultiphaseError::EmptyBounds); @@ -380,6 +407,10 @@ fn overlaps(rects: impl IntoIterator) -> bool { false } +fn motion_bounds(window: MultiphaseWindow) -> Rect { + window.from.union(window.to) +} + fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { if from != to { steps.push(MultiphaseStep { node_id, from, to }); @@ -697,4 +728,48 @@ mod tests { }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); } + + #[test] + fn motion_groups_split_disjoint_layout_changes() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(400, 0, 500, 100), + }, + ]; + assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1], vec![2]]); + } + + #[test] + fn motion_groups_are_transitive() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(80, 0, 180, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(170, 0, 270, 100), + to: rect(250, 0, 350, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(90, 0, 180, 100), + to: rect(180, 0, 260, 100), + }, + ]; + assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1, 2]]); + } } diff --git a/src/state.rs b/src/state.rs index 4273ac08..835678f8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,7 +5,9 @@ use { animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, expand_damage_rect, - multiphase::{MultiphaseRequest, MultiphaseWindow, plan_no_overlap}, + multiphase::{ + MultiphaseRequest, MultiphaseWindow, partition_motion_groups, plan_no_overlap, + }, spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, @@ -1586,51 +1588,59 @@ 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), + .visual_rect(candidate.node_id, candidate.old, now), to: candidate.new, }) .collect(); - let Some(first) = windows.first() else { + for group in partition_motion_groups(&windows) { + if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) { + continue; + } + for idx in group { + self.start_layout_animation_candidate(candidates[idx].clone(), now); + } + } + } + + fn start_multiphase_layout_animation( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + now_nsec: u64, + ) -> bool { + if group.len() < 2 { + return false; + } + let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect(); + let Some(first) = request_windows.first() else { return false; }; let mut bounds = first.from.union(first.to); - for window in &windows[1..] { + for window in &request_windows[1..] { bounds = bounds.union(window.from).union(window.to); } - let Ok(plan) = plan_no_overlap(&MultiphaseRequest { bounds, windows }) else { + let Ok(plan) = plan_no_overlap(&MultiphaseRequest { + bounds, + windows: request_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); + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let mut current = window.from; + let mut damage = current.union(window.to); let mut phases = vec![]; for phase in &plan.phases { match phase @@ -1646,7 +1656,7 @@ impl State { None => phases.push((current, current)), } } - if current != candidate.new { + if current != window.to { return false; } let retained = self