From 0b6da9d8e07583e3b819f8d517f926c37163e0a2 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 20:38:12 +1000 Subject: [PATCH] Order nested scale phases by hierarchy --- docs/window-animations-plan.md | 10 +- src/animation/multiphase.rs | 231 ++++++++++++++++++++++++++++++--- src/tree/toplevel.rs | 21 ++- 3 files changed, 237 insertions(+), 25 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index bc8bbeab..a9dedab3 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -225,6 +225,9 @@ Current pure planner status: only after space exists, and orthogonal growth happens in the final phase. - Same-axis size redistribution is handled as a single scale phase when the exact validator proves adjacent windows stay non-overlapping. +- Nested size redistribution can use hierarchy metadata to decompose two-axis + resizing into parent-axis then child-axis scale phases, but only when the + source/target ancestor split depths give a deterministic order. - Every produced plan is checked analytically for overlap over the full duration of each phase before it is accepted. This solves the linear edge inequalities for each pair of moving rectangles instead of relying on sampled frames. @@ -232,9 +235,9 @@ Current pure planner status: groups can still use multiphase animation when another group falls back to linear motion. - Planner requests now carry per-window hierarchy metadata for source/target - parent, depth, sibling index, split axis, mono state, and transition kind. - The current planner records this information but does not yet use it to order - nested-container phases. + parent, depth, sibling index, split axis, nearest ancestor split depth per + axis, mono state, and transition kind. The current planner uses this for + parent-before-child scale ordering, but not yet for full nested move planning. - Multiphase planning has a diagnostic entry point used by live fallback logs. It distinguishes request validation errors, missing patterns, shrink-bound rejections, invalid phase steps, and exact validation failures such as stale @@ -247,6 +250,7 @@ Tests: - horizontal swaps shrink, move, then grow without overlap - extraction from a stack creates space before moving the extracted window +- nested size redistribution scales the parent axis before the child axis - nested containers do not produce simultaneous cross-axis motion - interruption restarts only affected phase groups - reversing direction produces equivalent motion in reverse diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index f33cc834..828bc966 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -85,6 +85,8 @@ pub struct MultiphaseHierarchyPosition { pub depth: u16, pub sibling_index: Option, pub split_axis: Option, + pub nearest_horizontal_split_depth: Option, + pub nearest_vertical_split_depth: Option, pub parent_is_mono: bool, pub mono_active: bool, } @@ -320,6 +322,13 @@ fn plan_forward(request: &MultiphaseRequest) -> Result return Ok(plan), + Err(MultiphasePlanFailure::NoPattern) => {} + Err(error) => { + rejection.get_or_insert(error); + } + } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), @@ -383,6 +392,96 @@ fn plan_single_action_phase( build_validated_plan(request, [(action.kind, action.axis, steps)]) } +fn plan_hierarchy_ordered_axis_scales( + request: &MultiphaseRequest, +) -> Result { + let mut changed_axes = vec![]; + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + if request + .windows + .iter() + .any(|window| interval_changed(window.from, window.to, axis)) + { + changed_axes.push(axis); + } + } + let [first_axis, second_axis] = changed_axes + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + let axes = hierarchy_scale_axis_order(request, first_axis, second_axis) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + let mut phases = vec![]; + for axis in axes { + let mut steps = vec![]; + for window in &request.windows { + let (_, rect) = current + .iter_mut() + .find(|(node_id, _)| *node_id == window.node_id) + .unwrap(); + let next = with_main_interval( + *rect, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + if next == *rect { + continue; + } + if main_size(*rect, axis) == main_size(next, axis) { + return Err(MultiphasePlanFailure::NoPattern); + } + steps.push(MultiphaseStep { + node_id: window.node_id, + from: *rect, + to: next, + }); + *rect = next; + } + if steps.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + phases.push((PhaseKind::Scale, axis, steps)); + } + let [first, second] = phases + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + build_validated_plan(request, [first, second]) +} + +fn hierarchy_scale_axis_order( + request: &MultiphaseRequest, + first_axis: PhaseAxis, + second_axis: PhaseAxis, +) -> Option<[PhaseAxis; 2]> { + let first_priority = hierarchy_axis_priority(request, first_axis)?; + let second_priority = hierarchy_axis_priority(request, second_axis)?; + match first_priority.cmp(&second_priority) { + std::cmp::Ordering::Less => Some([first_axis, second_axis]), + std::cmp::Ordering::Greater => Some([second_axis, first_axis]), + std::cmp::Ordering::Equal => None, + } +} + +fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option { + request + .windows + .iter() + .filter(|window| interval_changed(window.from, window.to, axis)) + .flat_map(|window| { + [ + split_depth_for_axis(window.hierarchy.source, axis), + split_depth_for_axis(window.hierarchy.target, axis), + ] + }) + .flatten() + .min() +} + fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, @@ -847,6 +946,17 @@ fn motion_bounds(window: MultiphaseWindow) -> Rect { window.from.union(window.to) } +fn interval_changed(from: Rect, to: Rect, axis: PhaseAxis) -> bool { + main_start(from, axis) != main_start(to, axis) || main_end(from, axis) != main_end(to, axis) +} + +fn split_depth_for_axis(position: MultiphaseHierarchyPosition, axis: PhaseAxis) -> Option { + match axis { + PhaseAxis::Horizontal => position.nearest_horizontal_split_depth, + PhaseAxis::Vertical => position.nearest_vertical_split_depth, + } +} + fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { if from != to { steps.push(MultiphaseStep { node_id, from, to }); @@ -951,18 +1061,37 @@ mod tests { fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec { let mut leaves = vec![]; - layout_tree_inner(tree, bounds, None, 0, None, None, &mut leaves); + layout_tree_inner( + tree, + bounds, + TestHierarchy { + parent: None, + depth: 0, + sibling_index: None, + split_axis: None, + nearest_horizontal_split_depth: None, + nearest_vertical_split_depth: None, + }, + &mut leaves, + ); leaves.sort_by_key(|leaf| leaf.node_id.0); leaves } + #[derive(Copy, Clone)] + struct TestHierarchy { + parent: Option, + depth: u16, + sibling_index: Option, + split_axis: Option, + nearest_horizontal_split_depth: Option, + nearest_vertical_split_depth: Option, + } + fn layout_tree_inner( tree: &TestTree, bounds: Rect, - parent: Option, - depth: u16, - sibling_index: Option, - split_axis: Option, + hierarchy: TestHierarchy, leaves: &mut Vec, ) { match tree { @@ -970,10 +1099,12 @@ mod tests { node_id: id(*raw), rect: bounds, hierarchy: MultiphaseHierarchyPosition { - parent, - depth, - sibling_index, - split_axis, + parent: hierarchy.parent, + depth: hierarchy.depth, + sibling_index: hierarchy.sibling_index, + split_axis: hierarchy.split_axis, + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, ..Default::default() }, }), @@ -986,15 +1117,24 @@ mod tests { assert_eq!(weights.len(), children.len()); let rects = split_rect_by_weights(bounds, *axis, weights); for (idx, (child, rect)) in children.iter().zip(rects).enumerate() { - layout_tree_inner( - child, - rect, - Some(id(*split_id)), - depth.saturating_add(1), - Some(idx.min(u16::MAX as usize) as u16), - Some(*axis), - leaves, - ); + let depth = hierarchy.depth.saturating_add(1); + let mut child_hierarchy = TestHierarchy { + parent: Some(id(*split_id)), + depth, + sibling_index: Some(idx.min(u16::MAX as usize) as u16), + split_axis: Some(*axis), + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, + }; + match axis { + PhaseAxis::Horizontal => { + child_hierarchy.nearest_horizontal_split_depth = Some(depth); + } + PhaseAxis::Vertical => { + child_hierarchy.nearest_vertical_split_depth = Some(depth); + } + } + layout_tree_inner(child, rect, child_hierarchy, leaves); } } } @@ -1249,6 +1389,57 @@ mod tests { assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); } + #[test] + fn generated_nested_size_redistribution_scales_parent_axis_first() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 3], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[3, 1], vec![leaf(2), leaf(3)]), + ], + ); + let req = generated_request(&old, &new, rect(0, 0, 400, 100)); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 100)); + assert_eq!(step_to(&plan, 0, id(2)), rect(100, 0, 400, 50)); + assert_eq!(step_to(&plan, 0, id(3)), rect(100, 50, 400, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn two_axis_redistribution_without_hierarchy_still_falls_back() { + let req = request(vec![ + window(1, rect(0, 0, 200, 100), rect(0, 0, 100, 100)), + window(2, rect(200, 0, 400, 50), rect(100, 0, 400, 75)), + window(3, rect(200, 50, 400, 100), rect(100, 75, 400, 100)), + ]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + } + #[test] fn generated_stack_extractions_plan_for_both_axes_and_directions() { let horizontal_old = split( @@ -1525,6 +1716,8 @@ mod tests { depth: 2, sibling_index: Some(0), split_axis: Some(PhaseAxis::Vertical), + nearest_horizontal_split_depth: Some(1), + nearest_vertical_split_depth: Some(2), ..Default::default() }; let target = MultiphaseHierarchyPosition { @@ -1532,12 +1725,14 @@ mod tests { depth: 1, sibling_index: Some(2), split_axis: Some(PhaseAxis::Horizontal), + nearest_horizontal_split_depth: Some(1), ..Default::default() }; assert_eq!( MultiphaseWindowHierarchy::new(source, target).transition, MultiphaseHierarchyTransition::Ascending ); + assert_eq!(source.nearest_vertical_split_depth, Some(2)); let entering_mono = MultiphaseWindowHierarchy::new( source, diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index d5ace6bb..551f48ec 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -330,9 +330,9 @@ pub trait ToplevelNodeBase: Node { }; let mut position = MultiphaseHierarchyPosition { parent: Some(parent.node_id()), - depth: multiphase_parent_depth(Some(parent.clone())), ..Default::default() }; + populate_multiphase_ancestor_splits(&mut position, Some(parent.clone())); if let Some(container) = parent.node_into_container() { position.split_axis = Some(match container.split.get() { ContainerSplit::Horizontal => PhaseAxis::Horizontal, @@ -421,16 +421,29 @@ pub trait ToplevelNodeBase: Node { } } -fn multiphase_parent_depth(mut parent: Option>) -> u16 { +fn populate_multiphase_ancestor_splits( + position: &mut MultiphaseHierarchyPosition, + mut parent: Option>, +) { let mut depth = 0u16; while let Some(node) = parent { - let Some(toplevel) = node.node_into_toplevel() else { + let Some(toplevel) = node.clone().node_into_toplevel() else { break; }; depth = depth.saturating_add(1); + if let Some(container) = node.node_into_container() { + match container.split.get() { + ContainerSplit::Horizontal => { + position.nearest_horizontal_split_depth.get_or_insert(depth); + } + ContainerSplit::Vertical => { + position.nearest_vertical_split_depth.get_or_insert(depth); + } + } + } parent = toplevel.tl_data().parent.get(); } - depth + position.depth = depth; } pub struct FullscreenedData {