From b211b5352824ecdc7974aeabf737c6c79e1171c3 Mon Sep 17 00:00:00 2001 From: atagen Date: Fri, 22 May 2026 16:35:44 +1000 Subject: [PATCH] Handle phased animation retargeting --- docs/window-animations-testing.md | 27 ++++++-- src/animation.rs | 106 ++++++++++++++++++++++++++++++ src/animation/multiphase.rs | 82 +++++++++++++++++++++++ src/state.rs | 55 +++++++++++++++- 4 files changed, 263 insertions(+), 7 deletions(-) diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md index d464b0fe..e5d18900 100644 --- a/docs/window-animations-testing.md +++ b/docs/window-animations-testing.md @@ -288,8 +288,27 @@ Hard failure: ## 9. Mixed-Action Phases -Look for layouts where one window can move on one axis while another window -scales on a different axis in the same proven phase. +This case is easiest to prove with the planner tests because Wry currently has +few user commands that create a same-batch move for one tiled window and an +independent resize for another. The canonical geometry is: + +```text +start: A = (0,0)-(80,80) B = (200,0)-(280,80) +target: A = (40,0)-(120,80) B = (200,0)-(280,120) +``` + +`A` moves horizontally while `B` scales vertically. The windows are far enough +apart that the mixed phase is provably non-overlapping. + +To exercise the current proof directly, run: + +```sh +cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven +``` + +For visual/manual testing, use any command sequence that can produce the same +shape in one layout batch: one window changes only x-position, and a separate, +non-overlapping window changes only height. Expected: @@ -298,8 +317,8 @@ Expected: - no individual window moves diagonally - no overlap occurs at any point during the phase -This is easier to confirm with debug fallback logs plus visual inspection. A -fallback here is acceptable if the planner cannot prove the sequence. +A fallback here is acceptable if no normal user command can create this geometry; +the planner test above is the authority for the mixed-action rule. ## 10. Mono Mode diff --git a/src/animation.rs b/src/animation.rs index 19647b80..d0fafb1f 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -474,6 +474,20 @@ impl AnimationState { } } + pub fn phased_route_to( + &self, + node_id: NodeId, + target: Rect, + now_nsec: u64, + ) -> Option> { + let phased = self.phased.borrow(); + let anim = phased.get(&node_id)?; + if anim.done(now_nsec) { + return None; + } + anim.route_to(target, now_nsec) + } + pub fn exit_frames(&self, now_nsec: u64) -> Vec { self.exits .borrow() @@ -614,6 +628,52 @@ impl PhasedWindowAnimation { let t = self.curve.sample(t); lerp_rect(segment.from, segment.to, t) } + + fn phase_at(&self, now_nsec: u64) -> Option { + if self.duration_nsec == 0 || self.segments.is_empty() { + return None; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + (phase < self.segments.len()).then_some(phase) + } + + fn path_points(&self) -> Option> { + let first = self.segments.first()?; + let mut points = vec![first.from]; + points.extend(self.segments.iter().map(|segment| segment.to)); + Some(points) + } + + fn route_to(&self, target: Rect, now_nsec: u64) -> Option> { + let phase = self.phase_at(now_nsec)?; + let points = self.path_points()?; + let target_idx = points.iter().position(|point| *point == target)?; + let current = self.rect_at(now_nsec); + if current == target { + return Some(vec![]); + } + + let mut route = vec![]; + if target_idx <= phase { + push_non_empty_segment(&mut route, current, points[phase]); + for idx in (target_idx..phase).rev() { + push_non_empty_segment(&mut route, points[idx + 1], points[idx]); + } + } else { + push_non_empty_segment(&mut route, current, points[phase + 1]); + for idx in (phase + 1)..target_idx { + push_non_empty_segment(&mut route, points[idx], points[idx + 1]); + } + } + Some(route) + } +} + +fn push_non_empty_segment(route: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { + if from != to { + route.push((from, to)); + } } struct ExitAnimation { @@ -863,6 +923,52 @@ mod tests { assert_eq!(state.visual_rect(id, c, 200_000_000), c); } + #[test] + fn phased_route_reverses_to_existing_endpoint() { + 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 + )); + + let current = lerp_rect(b, c, 0.5); + assert_eq!( + state.phased_route_to(id, a, 150_000_000).unwrap(), + vec![(current, b), (b, a)] + ); + } + + #[test] + fn phased_route_continues_to_existing_endpoint() { + 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 + )); + + let current = lerp_rect(a, b, 0.5); + assert_eq!( + state.phased_route_to(id, c, 50_000_000).unwrap(), + vec![(current, b), (b, c)] + ); + } + #[test] fn linear_retarget_interrupts_phased_animation_from_current_rect() { let state = AnimationState::default(); diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index ff7c111c..b4397c2a 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -402,6 +402,52 @@ pub fn plan_no_overlap_explained( } } +pub(crate) fn validate_phase_paths( + request: &MultiphaseRequest, + paths: &[Vec<(Rect, Rect)>], +) -> Result { + if paths.len() != request.windows.len() { + return Err(MultiphasePlanFailure::NoPattern); + } + let phase_count = paths.iter().map(Vec::len).max().unwrap_or(0); + if phase_count == 0 { + return Err(MultiphasePlanFailure::NoPattern); + } + let mut phases = vec![]; + for phase_idx in 0..phase_count { + let mut steps = vec![]; + let mut actions = vec![]; + for (window_idx, path) in paths.iter().enumerate() { + let Some((from, to)) = path.get(phase_idx).copied() else { + continue; + }; + if from == to { + continue; + } + let step = MultiphaseStep { + node_id: request.windows[window_idx].node_id, + from, + to, + }; + let Some(action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + steps.push(step); + actions.push(action); + } + if !steps.is_empty() { + phases.push(MultiphasePhase { + action: MultiphasePhaseAction::from_step_actions(actions), + steps, + }); + } + } + let plan = MultiphasePlan { phases }; + validate_plan_continuous_diagnostic(request, &plan) + .map(|_| plan) + .map_err(MultiphasePlanFailure::Validation) +} + pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec> { let mut groups = vec![]; let mut seen = vec![false; windows.len()]; @@ -2378,6 +2424,42 @@ mod tests { assert!(validate_plan_continuous(&req, plan)); } + #[test] + fn validated_phase_paths_accept_interrupted_reverse_route() { + let a_current = rect(50, 0, 150, 50); + let b_current = rect(50, 50, 150, 100); + let req = request(vec![ + window(1, a_current, rect(0, 0, 100, 100)), + window(2, b_current, rect(100, 0, 200, 100)), + ]); + let paths = vec![ + vec![ + (a_current, rect(0, 0, 100, 50)), + (rect(0, 0, 100, 50), rect(0, 0, 100, 100)), + ], + vec![ + (b_current, rect(100, 50, 200, 100)), + (rect(100, 50, 200, 100), rect(100, 0, 200, 100)), + ], + ]; + + let plan = validate_phase_paths(&req, &paths).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &plan)); + } + #[test] fn bounded_generated_supported_split_tree_corpus_is_deterministic() { let mut cases = vec![]; diff --git a/src/state.rs b/src/state.rs index c21b575d..e0cc4469 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,7 @@ use { expand_damage_rect, multiphase::{ MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, - partition_motion_groups, plan_no_overlap_with_diagnostics, + partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, }, spawn_in_start_rect, }, @@ -1679,6 +1679,9 @@ impl State { windows: request_windows, clearance: self.layout_animation_clearance(), }; + if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) { + return true; + } let plan = match plan_no_overlap_with_diagnostics(&request) { Ok(plan) => plan, Err(diagnostic) => { @@ -1690,7 +1693,53 @@ impl State { return false; } }; - if plan.phases.is_empty() { + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_existing_phased_retarget( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + request: &MultiphaseRequest, + now_nsec: u64, + ) -> bool { + let mut paths = vec![]; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let Some(path) = + self.animations + .phased_route_to(candidate.node_id, window.to, now_nsec) + else { + return false; + }; + paths.push(path); + } + let plan = match validate_phase_paths(request, &paths) { + Ok(plan) => plan, + Err(error) => { + log::debug!( + "existing phased retarget rejected for group {:?}: {:?}", + group, + error + ); + return false; + } + }; + log::debug!("retargeting active phased animation for group {:?}", group); + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_multiphase_plan( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + plan_phases: &[crate::animation::multiphase::MultiphasePhase], + now_nsec: u64, + ) -> bool { + if plan_phases.is_empty() { return false; } let mut entries = vec![]; @@ -1700,7 +1749,7 @@ impl State { let mut current = window.from; let mut damage = current.union(window.to); let mut phases = vec![]; - for phase in &plan.phases { + for phase in plan_phases { match phase .steps .iter()