1
0
Fork 0
forked from wry/wry

Order nested scale phases by hierarchy

This commit is contained in:
atagen 2026-05-21 20:38:12 +10:00
parent a770089b88
commit 0b6da9d8e0
3 changed files with 237 additions and 25 deletions

View file

@ -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

View file

@ -85,6 +85,8 @@ pub struct MultiphaseHierarchyPosition {
pub depth: u16,
pub sibling_index: Option<u16>,
pub split_axis: Option<PhaseAxis>,
pub nearest_horizontal_split_depth: Option<u16>,
pub nearest_vertical_split_depth: Option<u16>,
pub parent_is_mono: bool,
pub mono_active: bool,
}
@ -320,6 +322,13 @@ fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, Multiphas
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);
}
}
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<MultiphasePlan, MultiphasePlanFailure> {
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<u16> {
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<u16> {
match axis {
PhaseAxis::Horizontal => position.nearest_horizontal_split_depth,
PhaseAxis::Vertical => position.nearest_vertical_split_depth,
}
}
fn push_step(steps: &mut Vec<MultiphaseStep>, 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<TestLeaf> {
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<NodeId>,
depth: u16,
sibling_index: Option<u16>,
split_axis: Option<PhaseAxis>,
nearest_horizontal_split_depth: Option<u16>,
nearest_vertical_split_depth: Option<u16>,
}
fn layout_tree_inner(
tree: &TestTree,
bounds: Rect,
parent: Option<NodeId>,
depth: u16,
sibling_index: Option<u16>,
split_axis: Option<PhaseAxis>,
hierarchy: TestHierarchy,
leaves: &mut Vec<TestLeaf>,
) {
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,

View file

@ -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<Rc<dyn ContainingNode>>) -> u16 {
fn populate_multiphase_ancestor_splits(
position: &mut MultiphaseHierarchyPosition,
mut parent: Option<Rc<dyn ContainingNode>>,
) {
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 {