diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 61c91b84..bc8bbeab 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -223,6 +223,8 @@ Current pure planner status: - Stack extraction/return patterns are covered in both horizontal and vertical orientations: peer/container space scales first, the extracted child moves 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. - 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. @@ -237,6 +239,9 @@ Current pure planner status: It distinguishes request validation errors, missing patterns, shrink-bound rejections, invalid phase steps, and exact validation failures such as stale starts or phase overlap. +- Planner tests now include a deterministic split-tree generator. It builds + valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them + through supported transitions, and runs the real planner plus exact validator. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 641e8826..f33cc834 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -219,6 +219,12 @@ pub fn plan_no_overlap_with_diagnostics( { return Ok(MultiphasePlan { phases: vec![] }); } + if let Some(failure) = target_shrink_bound_failure(request) { + return Err(MultiphasePlanDiagnostic { + forward: failure, + reverse: None, + }); + } let forward = match plan_forward(request) { Ok(plan) => return Ok(plan), Err(error) => error, @@ -283,8 +289,37 @@ fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> Ok(()) } +fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option { + let min_width = sane_min_size(request.bounds.width()); + let min_height = sane_min_size(request.bounds.height()); + for window in &request.windows { + if window.to.width() < min_width { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: window.to.width(), + required: min_width, + }); + } + if window.to.height() < min_height { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Vertical, + available: window.to.height(), + required: min_height, + }); + } + } + None +} + fn plan_forward(request: &MultiphaseRequest) -> Result { let mut rejection = None; + match plan_single_action_phase(request) { + Ok(plan) => 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), @@ -306,6 +341,48 @@ fn plan_forward(request: &MultiphaseRequest) -> Result Result { + let mut action = None; + let mut steps = vec![]; + for window in &request.windows { + if window.from == window.to { + continue; + } + let step = MultiphaseStep { + node_id: window.node_id, + from: window.from, + to: window.to, + }; + let Some(step_action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + if step_action.kind == PhaseKind::Scale { + let (available, required) = match step_action.axis { + PhaseAxis::Horizontal => (window.to.width(), sane_min_size(request.bounds.width())), + PhaseAxis::Vertical => (window.to.height(), sane_min_size(request.bounds.height())), + }; + if available < required { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: step_action.axis, + available, + required, + }); + } + } + if action.is_some_and(|action| action != step_action) { + return Err(MultiphasePlanFailure::NoPattern); + } + action = Some(step_action); + steps.push(step); + } + let Some(action) = action else { + return Err(MultiphasePlanFailure::NoPattern); + }; + build_validated_plan(request, [(action.kind, action.axis, steps)]) +} + fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, @@ -842,6 +919,154 @@ mod tests { MultiphaseWindow::new(id(raw), from, to) } + #[derive(Clone)] + enum TestTree { + Leaf(u32), + Split { + id: u32, + axis: PhaseAxis, + weights: Vec, + children: Vec, + }, + } + + struct TestLeaf { + node_id: NodeId, + rect: Rect, + hierarchy: MultiphaseHierarchyPosition, + } + + fn leaf(raw: u32) -> TestTree { + TestTree::Leaf(raw) + } + + fn split(id: u32, axis: PhaseAxis, weights: &[i32], children: Vec) -> TestTree { + TestTree::Split { + id, + axis, + weights: weights.to_vec(), + children, + } + } + + fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec { + let mut leaves = vec![]; + layout_tree_inner(tree, bounds, None, 0, None, None, &mut leaves); + leaves.sort_by_key(|leaf| leaf.node_id.0); + leaves + } + + fn layout_tree_inner( + tree: &TestTree, + bounds: Rect, + parent: Option, + depth: u16, + sibling_index: Option, + split_axis: Option, + leaves: &mut Vec, + ) { + match tree { + TestTree::Leaf(raw) => leaves.push(TestLeaf { + node_id: id(*raw), + rect: bounds, + hierarchy: MultiphaseHierarchyPosition { + parent, + depth, + sibling_index, + split_axis, + ..Default::default() + }, + }), + TestTree::Split { + id: split_id, + axis, + weights, + children, + } => { + 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, + ); + } + } + } + } + + fn split_rect_by_weights(bounds: Rect, axis: PhaseAxis, weights: &[i32]) -> Vec { + let total_weight: i32 = weights.iter().sum(); + assert!(total_weight > 0); + let total_size = match axis { + PhaseAxis::Horizontal => bounds.width(), + PhaseAxis::Vertical => bounds.height(), + }; + let mut pos = match axis { + PhaseAxis::Horizontal => bounds.x1(), + PhaseAxis::Vertical => bounds.y1(), + }; + let mut remaining_size = total_size; + let mut remaining_weight = total_weight; + let mut rects = vec![]; + for (idx, weight) in weights.iter().enumerate() { + let size = if idx + 1 == weights.len() { + remaining_size + } else { + total_size * *weight / total_weight + }; + let rect = match axis { + PhaseAxis::Horizontal => { + Rect::new_sized_saturating(pos, bounds.y1(), size, bounds.height()) + } + PhaseAxis::Vertical => { + Rect::new_sized_saturating(bounds.x1(), pos, bounds.width(), size) + } + }; + rects.push(rect); + pos += size; + remaining_size -= size; + remaining_weight -= *weight; + if remaining_weight == 0 { + assert_eq!(remaining_size, 0); + } + } + rects + } + + fn generated_request(old: &TestTree, new: &TestTree, bounds: Rect) -> MultiphaseRequest { + let old_leaves = layout_tree(old, bounds); + let new_leaves = layout_tree(new, bounds); + assert_eq!(old_leaves.len(), new_leaves.len()); + let mut windows = vec![]; + for old_leaf in &old_leaves { + let new_leaf = new_leaves + .iter() + .find(|leaf| leaf.node_id == old_leaf.node_id) + .unwrap(); + windows.push(MultiphaseWindow::with_hierarchy( + old_leaf.node_id, + old_leaf.rect, + new_leaf.rect, + MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy), + )); + } + MultiphaseRequest { bounds, windows } + } + + fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) { + let req = generated_request(old, new, bounds); + assert!(!overlaps(req.windows.iter().map(|window| window.from))); + assert!(!overlaps(req.windows.iter().map(|window| window.to))); + let plan = plan_no_overlap(&req).unwrap(); + assert!(validate_plan_continuous(&req, &plan)); + } + fn request(windows: Vec) -> MultiphaseRequest { let bounds = windows .iter() @@ -964,6 +1189,105 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn generated_sibling_swaps_plan_for_both_axes() { + let bounds = rect(0, 0, 240, 240); + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + let old = split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]); + let new = split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]); + assert_generated_case_plans(&old, &new, bounds); + } + } + + #[test] + fn generated_size_redistributions_plan_as_single_axis_scale() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_req = + generated_request(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + let horizontal_plan = plan_no_overlap(&horizontal_req).unwrap(); + assert_eq!( + actions(&horizontal_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }] + ); + assert!(validate_plan_continuous(&horizontal_req, &horizontal_plan)); + + let vertical_old = split( + 10, + PhaseAxis::Vertical, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_new = split( + 10, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_req = generated_request(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + let vertical_plan = plan_no_overlap(&vertical_req).unwrap(); + assert_eq!( + actions(&vertical_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }] + ); + assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); + } + + #[test] + fn generated_stack_extractions_plan_for_both_axes_and_directions() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + assert_generated_case_plans(&horizontal_new, &horizontal_old, rect(0, 0, 400, 100)); + + let vertical_old = split( + 20, + PhaseAxis::Vertical, + &[1, 1], + vec![ + leaf(1), + split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let vertical_new = split( + 20, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400)); + } + #[test] fn stack_extraction_creates_space_before_moving_child() { let req = request(vec![