From a516b2e7215f16f0f2cba346260c373f98fdf084 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:42:45 +1000 Subject: [PATCH] Mirror stack extraction multiphase planning --- docs/window-animations-plan.md | 8 ++ src/animation/multiphase.rs | 148 ++++++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index d46b3bdb..a4e39525 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -214,6 +214,14 @@ Preferred approach: - If a legal no-overlap sequence cannot be found for a group, fall back to the linear animator for that group only. Float windows are outside this invariant. +Current pure planner status: + +- Two-window same-axis swaps use shrink lanes, move, then grow. +- 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. +- Every produced plan is sampled for overlap at each phase before it is accepted. + Tests: - horizontal swaps shrink, move, then grow without overlap diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 3130bb80..05c8617c 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -110,7 +110,8 @@ fn plan_forward(request: &MultiphaseRequest) -> Option { return Some(plan); } } - plan_horizontal_space_then_vertical_growth(request) + plan_space_then_orthogonal_growth(request, PhaseAxis::Horizontal) + .or_else(|| plan_space_then_orthogonal_growth(request, PhaseAxis::Vertical)) } fn plan_axis_crossing_lanes( @@ -178,12 +179,14 @@ fn plan_axis_crossing_lanes( ) } -fn plan_horizontal_space_then_vertical_growth( +fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, + axis: PhaseAxis, ) -> Option { if request.windows.len() < 2 { return None; } + let orth_axis = axis.other(); let min_width = sane_min_size(request.bounds.width()); let min_height = sane_min_size(request.bounds.height()); let mut phase1 = vec![]; @@ -193,31 +196,33 @@ fn plan_horizontal_space_then_vertical_growth( if window.to.width() < min_width || window.to.height() < min_height { return None; } - let x_changes = window.from.x1() != window.to.x1() || window.from.x2() != window.to.x2(); - let y_changes = window.from.y1() != window.to.y1() || window.from.y2() != window.to.y2(); - if x_changes && window.from.width() == window.to.width() { - let after_move = Rect::new_sized_saturating( - window.to.x1(), - window.from.y1(), - window.to.width(), - window.from.height(), + let main_changes = main_start(window.from, axis) != main_start(window.to, axis) + || main_end(window.from, axis) != main_end(window.to, axis); + let orth_changes = orth_start(window.from, axis) != orth_start(window.to, axis) + || orth_end(window.from, axis) != orth_end(window.to, axis); + if main_changes && main_size(window.from, axis) == main_size(window.to, axis) { + let after_move = with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), ); push_step(&mut phase2, window.node_id, window.from, after_move); - if y_changes { + if orth_changes { push_step(&mut phase3, window.node_id, after_move, window.to); } - } else if x_changes { - let after_x_scale = Rect::new_sized_saturating( - window.to.x1(), - window.from.y1(), - window.to.width(), - window.from.height(), + } else if main_changes { + let after_main_scale = with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), ); - push_step(&mut phase1, window.node_id, window.from, after_x_scale); - if y_changes { - push_step(&mut phase3, window.node_id, after_x_scale, window.to); + push_step(&mut phase1, window.node_id, window.from, after_main_scale); + if orth_changes { + push_step(&mut phase3, window.node_id, after_main_scale, window.to); } - } else if y_changes { + } else if orth_changes { push_step(&mut phase3, window.node_id, window.from, window.to); } } @@ -227,9 +232,9 @@ fn plan_horizontal_space_then_vertical_growth( build_validated_plan( request, [ - (PhaseKind::Scale, PhaseAxis::Horizontal, phase1), - (PhaseKind::Move, PhaseAxis::Horizontal, phase2), - (PhaseKind::Scale, PhaseAxis::Vertical, phase3), + (PhaseKind::Scale, axis, phase1), + (PhaseKind::Move, axis, phase2), + (PhaseKind::Scale, orth_axis, phase3), ], ) } @@ -444,10 +449,12 @@ mod tests { } fn request(windows: Vec) -> MultiphaseRequest { - MultiphaseRequest { - bounds: rect(0, 0, 400, 100), - windows, - } + let bounds = windows + .iter() + .map(|window| window.from.union(window.to)) + .reduce(|bounds, rect| bounds.union(rect)) + .unwrap_or_else(|| rect(0, 0, 1, 1)); + MultiphaseRequest { bounds, windows } } fn actions(plan: &MultiphasePlan) -> Vec { @@ -596,6 +603,91 @@ mod tests { assert!(validate_plan_samples(&req, &plan)); } + #[test] + fn vertical_stack_extraction_creates_space_before_moving_child() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 200), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 200, 50, 400), + to: rect(0, 100, 100, 300), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(50, 200, 100, 400), + to: rect(0, 300, 100, 400), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100)); + assert_eq!(plan.phases[0].steps[1].to, rect(50, 300, 100, 400)); + assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300)); + assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300)); + assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn vertical_stack_extraction_reverse_replays_phases_in_reverse() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 100, 200), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 100, 100, 300), + to: rect(0, 200, 50, 400), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(0, 300, 100, 400), + to: rect(50, 200, 100, 400), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert!(validate_plan_samples(&req, &plan)); + } + #[test] fn unsupported_diagonal_motion_falls_back_to_linear() { let req = request(vec![MultiphaseWindow {