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. 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 - Same-axis size redistribution is handled as a single scale phase when the
exact validator proves adjacent windows stay non-overlapping. 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 - 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 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. 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 groups can still use multiphase animation when another group falls back to
linear motion. linear motion.
- Planner requests now carry per-window hierarchy metadata for source/target - Planner requests now carry per-window hierarchy metadata for source/target
parent, depth, sibling index, split axis, mono state, and transition kind. parent, depth, sibling index, split axis, nearest ancestor split depth per
The current planner records this information but does not yet use it to order axis, mono state, and transition kind. The current planner uses this for
nested-container phases. 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. - Multiphase planning has a diagnostic entry point used by live fallback logs.
It distinguishes request validation errors, missing patterns, shrink-bound It distinguishes request validation errors, missing patterns, shrink-bound
rejections, invalid phase steps, and exact validation failures such as stale rejections, invalid phase steps, and exact validation failures such as stale
@ -247,6 +250,7 @@ Tests:
- horizontal swaps shrink, move, then grow without overlap - horizontal swaps shrink, move, then grow without overlap
- extraction from a stack creates space before moving the extracted window - 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 - nested containers do not produce simultaneous cross-axis motion
- interruption restarts only affected phase groups - interruption restarts only affected phase groups
- reversing direction produces equivalent motion in reverse - reversing direction produces equivalent motion in reverse

View file

@ -85,6 +85,8 @@ pub struct MultiphaseHierarchyPosition {
pub depth: u16, pub depth: u16,
pub sibling_index: Option<u16>, pub sibling_index: Option<u16>,
pub split_axis: Option<PhaseAxis>, 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 parent_is_mono: bool,
pub mono_active: bool, pub mono_active: bool,
} }
@ -320,6 +322,13 @@ fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, Multiphas
rejection.get_or_insert(error); 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] { for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_axis_crossing_lanes(request, axis) { match plan_axis_crossing_lanes(request, axis) {
Ok(plan) => return Ok(plan), Ok(plan) => return Ok(plan),
@ -383,6 +392,96 @@ fn plan_single_action_phase(
build_validated_plan(request, [(action.kind, action.axis, steps)]) 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( fn plan_axis_crossing_lanes(
request: &MultiphaseRequest, request: &MultiphaseRequest,
axis: PhaseAxis, axis: PhaseAxis,
@ -847,6 +946,17 @@ fn motion_bounds(window: MultiphaseWindow) -> Rect {
window.from.union(window.to) 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) { fn push_step(steps: &mut Vec<MultiphaseStep>, node_id: NodeId, from: Rect, to: Rect) {
if from != to { if from != to {
steps.push(MultiphaseStep { node_id, from, to }); steps.push(MultiphaseStep { node_id, from, to });
@ -951,18 +1061,37 @@ mod tests {
fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec<TestLeaf> { fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec<TestLeaf> {
let mut leaves = 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.sort_by_key(|leaf| leaf.node_id.0);
leaves 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( fn layout_tree_inner(
tree: &TestTree, tree: &TestTree,
bounds: Rect, bounds: Rect,
parent: Option<NodeId>, hierarchy: TestHierarchy,
depth: u16,
sibling_index: Option<u16>,
split_axis: Option<PhaseAxis>,
leaves: &mut Vec<TestLeaf>, leaves: &mut Vec<TestLeaf>,
) { ) {
match tree { match tree {
@ -970,10 +1099,12 @@ mod tests {
node_id: id(*raw), node_id: id(*raw),
rect: bounds, rect: bounds,
hierarchy: MultiphaseHierarchyPosition { hierarchy: MultiphaseHierarchyPosition {
parent, parent: hierarchy.parent,
depth, depth: hierarchy.depth,
sibling_index, sibling_index: hierarchy.sibling_index,
split_axis, 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() ..Default::default()
}, },
}), }),
@ -986,15 +1117,24 @@ mod tests {
assert_eq!(weights.len(), children.len()); assert_eq!(weights.len(), children.len());
let rects = split_rect_by_weights(bounds, *axis, weights); let rects = split_rect_by_weights(bounds, *axis, weights);
for (idx, (child, rect)) in children.iter().zip(rects).enumerate() { for (idx, (child, rect)) in children.iter().zip(rects).enumerate() {
layout_tree_inner( let depth = hierarchy.depth.saturating_add(1);
child, let mut child_hierarchy = TestHierarchy {
rect, parent: Some(id(*split_id)),
Some(id(*split_id)), depth,
depth.saturating_add(1), sibling_index: Some(idx.min(u16::MAX as usize) as u16),
Some(idx.min(u16::MAX as usize) as u16), split_axis: Some(*axis),
Some(*axis), nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth,
leaves, 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)); 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] #[test]
fn generated_stack_extractions_plan_for_both_axes_and_directions() { fn generated_stack_extractions_plan_for_both_axes_and_directions() {
let horizontal_old = split( let horizontal_old = split(
@ -1525,6 +1716,8 @@ mod tests {
depth: 2, depth: 2,
sibling_index: Some(0), sibling_index: Some(0),
split_axis: Some(PhaseAxis::Vertical), split_axis: Some(PhaseAxis::Vertical),
nearest_horizontal_split_depth: Some(1),
nearest_vertical_split_depth: Some(2),
..Default::default() ..Default::default()
}; };
let target = MultiphaseHierarchyPosition { let target = MultiphaseHierarchyPosition {
@ -1532,12 +1725,14 @@ mod tests {
depth: 1, depth: 1,
sibling_index: Some(2), sibling_index: Some(2),
split_axis: Some(PhaseAxis::Horizontal), split_axis: Some(PhaseAxis::Horizontal),
nearest_horizontal_split_depth: Some(1),
..Default::default() ..Default::default()
}; };
assert_eq!( assert_eq!(
MultiphaseWindowHierarchy::new(source, target).transition, MultiphaseWindowHierarchy::new(source, target).transition,
MultiphaseHierarchyTransition::Ascending MultiphaseHierarchyTransition::Ascending
); );
assert_eq!(source.nearest_vertical_split_depth, Some(2));
let entering_mono = MultiphaseWindowHierarchy::new( let entering_mono = MultiphaseWindowHierarchy::new(
source, source,

View file

@ -330,9 +330,9 @@ pub trait ToplevelNodeBase: Node {
}; };
let mut position = MultiphaseHierarchyPosition { let mut position = MultiphaseHierarchyPosition {
parent: Some(parent.node_id()), parent: Some(parent.node_id()),
depth: multiphase_parent_depth(Some(parent.clone())),
..Default::default() ..Default::default()
}; };
populate_multiphase_ancestor_splits(&mut position, Some(parent.clone()));
if let Some(container) = parent.node_into_container() { if let Some(container) = parent.node_into_container() {
position.split_axis = Some(match container.split.get() { position.split_axis = Some(match container.split.get() {
ContainerSplit::Horizontal => PhaseAxis::Horizontal, 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; let mut depth = 0u16;
while let Some(node) = parent { while let Some(node) = parent {
let Some(toplevel) = node.node_into_toplevel() else { let Some(toplevel) = node.clone().node_into_toplevel() else {
break; break;
}; };
depth = depth.saturating_add(1); 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(); parent = toplevel.tl_data().parent.get();
} }
depth position.depth = depth;
} }
pub struct FullscreenedData { pub struct FullscreenedData {