diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index a9dedab3..ffda0711 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -242,6 +242,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. +- Multiphase planning also has an explained-plan entry point. Accepted plans + report the deterministic strategy, phase reasons, participating nodes, and + validation result; rejected plans report every attempted strategy and failure. - 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. @@ -254,6 +257,7 @@ Tests: - nested containers do not produce simultaneous cross-axis motion - interruption restarts only affected phase groups - reversing direction produces equivalent motion in reverse +- accepted and rejected plans expose deterministic strategy explanations - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 828bc966..2addc788 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -120,6 +120,75 @@ pub struct MultiphasePlan { pub phases: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanned { + pub plan: MultiphasePlan, + pub explanation: MultiphasePlanExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanExplanation { + pub strategy: PlanStrategy, + pub phases: Vec, + pub validation: ValidationExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhaseExplanation { + pub action: PhaseAction, + pub reason: PhaseReason, + pub nodes: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ValidationExplanation { + pub continuous_overlap_passed: bool, + pub final_rects_matched: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlanStrategy { + NoOp, + SingleAction, + HierarchyOrderedScales, + SwapLanes { axis: PhaseAxis }, + SpaceThenOrthogonalGrowth { axis: PhaseAxis }, + ReversedForwardPlan { original: Box }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PlanDirection { + Forward, + Reverse, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RejectedStrategy { + pub direction: PlanDirection, + pub strategy: PlanStrategy, + pub reason: MultiphasePlanFailure, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseReason { + SingleAction, + SameAxisRedistribution, + ShrinkIntoLanes { + lane_axis: PhaseAxis, + }, + MoveThroughFreedSpace, + GrowOutOfLanes, + CreateSpaceForAscendingChild, + MoveAscendingChildAfterSpaceExists, + OrthogonalGrowthAfterMove, + ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis, + parent_depth: u16, + child_axis: PhaseAxis, + child_depth: u16, + }, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MultiphasePhase { pub action: PhaseAction, @@ -161,10 +230,11 @@ pub enum MultiphaseError { NoPlan, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct MultiphasePlanDiagnostic { pub forward: MultiphasePlanFailure, pub reverse: Option, + pub attempted: Vec, } impl MultiphasePlanDiagnostic { @@ -176,6 +246,15 @@ impl MultiphasePlanDiagnostic { } } +impl ValidationExplanation { + fn passed() -> Self { + Self { + continuous_overlap_passed: true, + final_rects_matched: true, + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MultiphasePlanFailure { Request(MultiphaseError), @@ -201,6 +280,12 @@ pub enum MultiphaseValidationError { FinalMismatch { node_id: NodeId }, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlanForwardFailure { + reason: MultiphasePlanFailure, + attempted: Vec, +} + pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error()) } @@ -208,10 +293,17 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result Result { + plan_no_overlap_explained(request).map(|planned| planned.plan) +} + +pub fn plan_no_overlap_explained( + request: &MultiphaseRequest, +) -> Result { if let Err(error) = validate_request(request) { return Err(MultiphasePlanDiagnostic { forward: MultiphasePlanFailure::Request(error), reverse: None, + attempted: vec![], }); } if request @@ -219,25 +311,38 @@ pub fn plan_no_overlap_with_diagnostics( .iter() .all(|window| window.from == window.to) { - return Ok(MultiphasePlan { phases: vec![] }); + return Ok(MultiphasePlanned { + plan: MultiphasePlan { phases: vec![] }, + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::NoOp, + phases: vec![], + validation: ValidationExplanation::passed(), + }, + }); } if let Some(failure) = target_shrink_bound_failure(request) { return Err(MultiphasePlanDiagnostic { forward: failure, reverse: None, + attempted: vec![], }); } - let forward = match plan_forward(request) { + let forward = match plan_forward(request, PlanDirection::Forward) { Ok(plan) => return Ok(plan), Err(error) => error, }; let reversed = reverse_request(request); - match plan_forward(&reversed) { - Ok(plan) => Ok(reverse_plan(plan)), - Err(reverse) => Err(MultiphasePlanDiagnostic { - forward, - reverse: Some(reverse), - }), + match plan_forward(&reversed, PlanDirection::Reverse) { + Ok(plan) => Ok(reverse_planned(plan)), + Err(reverse) => { + let mut attempted = forward.attempted; + attempted.extend(reverse.attempted); + Err(MultiphasePlanDiagnostic { + forward: forward.reason, + reverse: Some(reverse.reason), + attempted, + }) + } } } @@ -313,46 +418,89 @@ fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option Result { +fn plan_forward( + request: &MultiphaseRequest, + direction: PlanDirection, +) -> Result { let mut rejection = None; + let mut attempted = vec![]; match plan_single_action_phase(request) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection(&mut attempted, direction, PlanStrategy::SingleAction, error); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } match plan_hierarchy_ordered_axis_scales(request) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::HierarchyOrderedScales, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::SwapLanes { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_space_then_orthogonal_growth(request, axis) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } } - Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern)) + Err(PlanForwardFailure { + reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern), + attempted, + }) +} + +fn record_rejection( + attempted: &mut Vec, + direction: PlanDirection, + strategy: PlanStrategy, + reason: MultiphasePlanFailure, +) { + attempted.push(RejectedStrategy { + direction, + strategy, + reason, + }); } fn plan_single_action_phase( request: &MultiphaseRequest, -) -> Result { +) -> Result { let mut action = None; let mut steps = vec![]; for window in &request.windows { @@ -389,12 +537,21 @@ fn plan_single_action_phase( let Some(action) = action else { return Err(MultiphasePlanFailure::NoPattern); }; - build_validated_plan(request, [(action.kind, action.axis, steps)]) + build_validated_plan( + request, + PlanStrategy::SingleAction, + [phase_draft( + action.kind, + action.axis, + steps, + single_action_reason(action), + )], + ) } fn plan_hierarchy_ordered_axis_scales( request: &MultiphaseRequest, -) -> Result { +) -> Result { let mut changed_axes = vec![]; for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { if request @@ -408,7 +565,7 @@ fn plan_hierarchy_ordered_axis_scales( let [first_axis, second_axis] = changed_axes .try_into() .map_err(|_| MultiphasePlanFailure::NoPattern)?; - let axes = hierarchy_scale_axis_order(request, first_axis, second_axis) + let order = hierarchy_scale_axis_order(request, first_axis, second_axis) .ok_or(MultiphasePlanFailure::NoPattern)?; let mut current: Vec<_> = request .windows @@ -416,7 +573,13 @@ fn plan_hierarchy_ordered_axis_scales( .map(|window| (window.node_id, window.from)) .collect(); let mut phases = vec![]; - for axis in axes { + let reason = PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: order.axes[0], + parent_depth: order.depths[0], + child_axis: order.axes[1], + child_depth: order.depths[1], + }; + for axis in order.axes { let mut steps = vec![]; for window in &request.windows { let (_, rect) = current @@ -445,28 +608,44 @@ fn plan_hierarchy_ordered_axis_scales( if steps.is_empty() { return Err(MultiphasePlanFailure::NoPattern); } - phases.push((PhaseKind::Scale, axis, steps)); + phases.push(phase_draft(PhaseKind::Scale, axis, steps, reason)); } let [first, second] = phases .try_into() .map_err(|_| MultiphasePlanFailure::NoPattern)?; - build_validated_plan(request, [first, second]) + build_validated_plan( + request, + PlanStrategy::HierarchyOrderedScales, + [first, second], + ) } fn hierarchy_scale_axis_order( request: &MultiphaseRequest, first_axis: PhaseAxis, second_axis: PhaseAxis, -) -> Option<[PhaseAxis; 2]> { +) -> Option { 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::Less => Some(HierarchyScaleAxisOrder { + axes: [first_axis, second_axis], + depths: [first_priority, second_priority], + }), + std::cmp::Ordering::Greater => Some(HierarchyScaleAxisOrder { + axes: [second_axis, first_axis], + depths: [second_priority, first_priority], + }), std::cmp::Ordering::Equal => None, } } +#[derive(Copy, Clone)] +struct HierarchyScaleAxisOrder { + axes: [PhaseAxis; 2], + depths: [u16; 2], +} + fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option { request .windows @@ -485,7 +664,7 @@ fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Opti fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Result { +) -> Result { if request.windows.len() != 2 { return Err(MultiphasePlanFailure::NoPattern); } @@ -551,10 +730,28 @@ fn plan_axis_crossing_lanes( } build_validated_plan( request, + PlanStrategy::SwapLanes { axis }, [ - (PhaseKind::Scale, axis.other(), phase1), - (PhaseKind::Move, axis, phase2), - (PhaseKind::Scale, axis.other(), phase3), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase1, + PhaseReason::ShrinkIntoLanes { + lane_axis: axis.other(), + }, + ), + phase_draft( + PhaseKind::Move, + axis, + phase2, + PhaseReason::MoveThroughFreedSpace, + ), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase3, + PhaseReason::GrowOutOfLanes, + ), ], ) } @@ -571,7 +768,7 @@ fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Result { +) -> Result { if request.windows.len() < 2 { return Err(MultiphasePlanFailure::NoPattern); } @@ -631,24 +828,71 @@ fn plan_space_then_orthogonal_growth( } build_validated_plan( request, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, [ - (PhaseKind::Scale, axis, phase1), - (PhaseKind::Move, axis, phase2), - (PhaseKind::Scale, orth_axis, phase3), + phase_draft( + PhaseKind::Scale, + axis, + phase1, + PhaseReason::CreateSpaceForAscendingChild, + ), + phase_draft( + PhaseKind::Move, + axis, + phase2, + PhaseReason::MoveAscendingChildAfterSpaceExists, + ), + phase_draft( + PhaseKind::Scale, + orth_axis, + phase3, + PhaseReason::OrthogonalGrowthAfterMove, + ), ], ) } +struct MultiphasePhaseDraft { + action: PhaseAction, + steps: Vec, + reason: PhaseReason, +} + +fn phase_draft( + kind: PhaseKind, + axis: PhaseAxis, + steps: Vec, + reason: PhaseReason, +) -> MultiphasePhaseDraft { + MultiphasePhaseDraft { + action: PhaseAction { kind, axis }, + steps, + reason, + } +} + fn build_validated_plan( request: &MultiphaseRequest, - phases: [(PhaseKind, PhaseAxis, Vec); N], -) -> Result { + strategy: PlanStrategy, + phases: [MultiphasePhaseDraft; N], +) -> Result { + let mut explanations = vec![]; let phases: Vec<_> = phases .into_iter() - .filter_map(|(kind, axis, steps)| { - (!steps.is_empty()).then_some(MultiphasePhase { - action: PhaseAction { kind, axis }, - steps, + .filter_map(|draft| { + if draft.steps.is_empty() { + return None; + } + let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect(); + nodes.sort_by_key(|node_id| node_id.0); + explanations.push(PhaseExplanation { + action: draft.action, + reason: draft.reason, + nodes, + }); + Some(MultiphasePhase { + action: draft.action, + steps: draft.steps, }) }) .collect(); @@ -664,7 +908,14 @@ fn build_validated_plan( } let plan = MultiphasePlan { phases }; validate_plan_continuous_diagnostic(request, &plan) - .map(|_| plan) + .map(|_| MultiphasePlanned { + plan, + explanation: MultiphasePlanExplanation { + strategy, + phases: explanations, + validation: ValidationExplanation::passed(), + }, + }) .map_err(MultiphasePlanFailure::Validation) } @@ -894,6 +1145,13 @@ fn classify_step(step: MultiphaseStep) -> Option { } } +fn single_action_reason(action: PhaseAction) -> PhaseReason { + match action.kind { + PhaseKind::Move => PhaseReason::SingleAction, + PhaseKind::Scale => PhaseReason::SameAxisRedistribution, + } +} + fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { MultiphaseRequest { bounds: request.bounds, @@ -932,6 +1190,21 @@ fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan { } } +fn reverse_planned(planned: MultiphasePlanned) -> MultiphasePlanned { + let mut phases = planned.explanation.phases; + phases.reverse(); + MultiphasePlanned { + plan: reverse_plan(planned.plan), + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::ReversedForwardPlan { + original: Box::new(planned.explanation.strategy), + }, + phases, + validation: planned.explanation.validation, + }, + } +} + fn overlaps(rects: impl IntoIterator) -> bool { let rects: Vec<_> = rects.into_iter().collect(); for (idx, rect) in rects.iter().enumerate() { @@ -1240,9 +1513,10 @@ mod tests { hierarchy: Default::default(), }, ]); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1260,7 +1534,29 @@ mod tests { ); assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); - assert!(validate_plan_continuous(&req, &plan)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal + } + ); + assert_eq!( + planned + .explanation + .phases + .iter() + .map(|phase| phase.reason) + .collect::>(), + vec![ + PhaseReason::ShrinkIntoLanes { + lane_axis: PhaseAxis::Vertical + }, + PhaseReason::MoveThroughFreedSpace, + PhaseReason::GrowOutOfLanes, + ] + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1410,9 +1706,10 @@ mod tests { ], ); let req = generated_request(&old, &new, rect(0, 0, 400, 100)); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1424,10 +1721,32 @@ mod tests { }, ] ); - 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)); + 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_eq!( + planned.explanation.strategy, + PlanStrategy::HierarchyOrderedScales + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis::Horizontal, + parent_depth: 1, + child_axis: PhaseAxis::Vertical, + child_depth: 2, + } + ); + assert_eq!( + planned.explanation.phases[0].nodes, + vec![id(1), id(2), id(3)] + ); + assert_eq!(planned.explanation.phases[1].nodes, vec![id(2), id(3)]); + assert_eq!( + planned.explanation.validation, + ValidationExplanation::passed() + ); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1549,9 +1868,10 @@ mod tests { hierarchy: Default::default(), }, ]); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1567,7 +1887,15 @@ mod tests { }, ] ); - assert!(validate_plan_continuous(&req, &plan)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::ReversedForwardPlan { + original: Box::new(PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal + }) + } + ); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1670,12 +1998,16 @@ mod tests { hierarchy: Default::default(), }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); - assert_eq!( - plan_no_overlap_with_diagnostics(&req).unwrap_err(), - MultiphasePlanDiagnostic { - forward: MultiphasePlanFailure::NoPattern, - reverse: Some(MultiphasePlanFailure::NoPattern), - } + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern); + assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern)); + assert!( + diagnostic + .attempted + .iter() + .any(|attempt| attempt.direction == PlanDirection::Forward + && attempt.strategy == PlanStrategy::SingleAction + && attempt.reason == MultiphasePlanFailure::NoPattern) ); }