1
0
Fork 0
forked from wry/wry

Refine animation planner test fixes

This commit is contained in:
atagen 2026-05-22 16:26:03 +10:00
parent d2138b45f6
commit 1a75f47709
3 changed files with 214 additions and 77 deletions

View file

@ -22,8 +22,6 @@ const DEFAULT_DURATION_MS: u32 = 160;
const CURVE_MAX_POINTS: usize = 33;
const CURVE_FLATNESS_EPSILON: f32 = 0.001;
const CURVE_MAX_DEPTH: u8 = 8;
const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4;
const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5;
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum AnimationCurve {
@ -296,7 +294,7 @@ impl AnimationState {
duration_ms: u32,
curve: AnimationCurve,
) -> bool {
if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 {
if old == new || new.is_empty() || duration_ms == 0 {
self.windows.borrow_mut().remove(&node_id);
self.phased.borrow_mut().remove(&node_id);
return false;
@ -420,7 +418,7 @@ impl AnimationState {
return false;
}
let to = spawn_in_start_rect(from);
if to == from || to.is_empty() {
if to == from {
return false;
}
let source_body_size = body_size_for_frame(from, frame_inset);
@ -690,20 +688,8 @@ impl LatchListener for AnimationTick {
}
pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect {
fn scaled_dimension(value: i32) -> i32 {
let scaled = (value as i64 * SPAWN_IN_INITIAL_SCALE_NUMERATOR as i64
/ SPAWN_IN_INITIAL_SCALE_DENOMINATOR as i64) as i32;
scaled.clamp(1, value.max(1))
}
let width = scaled_dimension(target.width());
let height = scaled_dimension(target.height());
Rect::new_sized_saturating(
target.x1() + (target.width() - width) / 2,
target.y1() + (target.height() - height) / 2,
width,
height,
)
let (cx, cy) = target.center();
Rect::new_empty(cx, cy)
}
fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) {
@ -936,12 +922,9 @@ mod tests {
}
#[test]
fn spawn_in_start_rect_is_centered_and_non_empty() {
fn spawn_in_start_rect_is_centered_and_empty() {
let target = Rect::new_sized_saturating(10, 20, 100, 50);
assert_eq!(
spawn_in_start_rect(target),
Rect::new_sized_saturating(20, 25, 80, 40)
);
assert_eq!(spawn_in_start_rect(target), Rect::new_empty(60, 45));
}
#[test]
@ -952,7 +935,7 @@ mod tests {
assert!(state.set_spawn_in(id, target, None, 0, 160));
assert_eq!(
state.visual_rect(id, target, 80_000_000),
Rect::new_sized_saturating(15, 23, 90, 45)
Rect::new_sized_saturating(35, 33, 50, 25)
);
}
}

View file

@ -6,6 +6,7 @@ const MIN_SHRINK_DENOMINATOR: i32 = 4;
pub struct MultiphaseRequest {
pub bounds: Rect,
pub windows: Vec<MultiphaseWindow>,
pub clearance: i32,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
@ -488,6 +489,22 @@ fn plan_forward(
}
}
}
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_space_then_orthogonal_growth(request, axis) {
Ok(plan) => return Ok(plan),
Err(error) => {
record_rejection(
&mut attempted,
direction,
PlanStrategy::SpaceThenOrthogonalGrowth { axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
match plan_hierarchy_ordered_axis_scales(request) {
Ok(plan) => return Ok(plan),
Err(error) => {
@ -534,22 +551,6 @@ fn plan_forward(
}
}
}
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_space_then_orthogonal_growth(request, axis) {
Ok(plan) => return Ok(plan),
Err(error) => {
record_rejection(
&mut attempted,
direction,
PlanStrategy::SpaceThenOrthogonalGrowth { axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
Err(PlanForwardFailure {
reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern),
attempted,
@ -750,7 +751,13 @@ fn plan_axis_crossing_lanes(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
if request.windows.len() != 2 {
let moving_windows: Vec<_> = request
.windows
.iter()
.copied()
.filter(|window| window.from != window.to)
.collect();
if moving_windows.len() != 2 {
return Err(MultiphasePlanFailure::NoPattern);
}
let orth_min = request
@ -765,7 +772,7 @@ fn plan_axis_crossing_lanes(
.map(|window| orth_end(window.from, axis))
.max()
.ok_or(MultiphasePlanFailure::NoPattern)?;
if request.windows.iter().any(|window| {
if moving_windows.iter().any(|window| {
main_size(window.from, axis) != main_size(window.to, axis)
|| orth_start(window.from, axis) != orth_min
|| orth_end(window.from, axis) != orth_max
@ -775,7 +782,18 @@ fn plan_axis_crossing_lanes(
}) {
return Err(MultiphasePlanFailure::NoPattern);
}
let lane_size = (orth_max - orth_min) / request.windows.len() as i32;
let clearance = request.clearance.max(0);
let lane_count = moving_windows.len() as i32;
let available = (orth_max - orth_min) - clearance.saturating_mul(lane_count - 1);
if available <= 0 {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: axis.other(),
available: 0,
required: sane_min_size(orth_max - orth_min),
});
}
let lane_size = available / lane_count;
let mut lane_remainder = available % lane_count;
let required = sane_min_size(orth_max - orth_min);
if lane_size < required {
return Err(MultiphasePlanFailure::ShrinkBound {
@ -785,7 +803,7 @@ fn plan_axis_crossing_lanes(
});
}
let mut windows = request.windows.clone();
let mut windows = moving_windows;
windows.sort_by_key(|window| lane_index_for_direction(*window, axis));
if windows.windows(2).any(|pair| {
lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis)
@ -795,13 +813,15 @@ fn plan_axis_crossing_lanes(
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
let mut lane_start = orth_min;
for (idx, window) in windows.iter().enumerate() {
let lane_start = orth_min + lane_size * idx as i32;
let lane_end = if idx + 1 == windows.len() {
orth_max
let extra = if lane_remainder > 0 {
lane_remainder -= 1;
1
} else {
lane_start + lane_size
0
};
let lane_end = lane_start + lane_size + extra;
let lane_from = with_orth_interval(window.from, axis, lane_start, lane_end);
let lane_to = with_main_interval(
lane_from,
@ -812,6 +832,9 @@ fn plan_axis_crossing_lanes(
push_step(&mut phase1, window.node_id, window.from, lane_from);
push_step(&mut phase2, window.node_id, lane_from, lane_to);
push_step(&mut phase3, window.node_id, lane_to, window.to);
if idx + 1 < windows.len() {
lane_start = lane_end + clearance;
}
}
build_validated_plan(
request,
@ -882,6 +905,7 @@ fn plan_space_then_orthogonal_growth(
|| 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);
let mut orth_from = window.from;
if main_changes && main_size(window.from, axis) == main_size(window.to, axis) {
let after_move = with_main_interval(
window.from,
@ -890,22 +914,50 @@ fn plan_space_then_orthogonal_growth(
main_end(window.to, axis),
);
push_step(&mut phase2, window.node_id, window.from, after_move);
if orth_changes {
push_step(&mut phase3, window.node_id, after_move, window.to);
}
orth_from = after_move;
} else if main_changes {
let after_main_scale = with_main_interval(
window.from,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
);
let target_size = main_size(window.to, axis);
let after_main_scale = if main_start(window.from, axis) == main_start(window.to, axis)
|| main_end(window.from, axis) == main_end(window.to, axis)
{
with_main_interval(
window.from,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
)
} else if main_start(window.to, axis) < main_start(window.from, axis) {
with_main_interval(
window.from,
axis,
main_end(window.from, axis) - target_size,
main_end(window.from, axis),
)
} else {
with_main_interval(
window.from,
axis,
main_start(window.from, axis),
main_start(window.from, axis) + target_size,
)
};
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);
orth_from = after_main_scale;
if main_start(after_main_scale, axis) != main_start(window.to, axis)
|| main_end(after_main_scale, axis) != main_end(window.to, axis)
{
let after_move = with_main_interval(
after_main_scale,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
);
push_step(&mut phase2, window.node_id, after_main_scale, after_move);
orth_from = after_move;
}
} else if orth_changes {
push_step(&mut phase3, window.node_id, window.from, window.to);
}
if orth_changes {
push_step(&mut phase3, window.node_id, orth_from, window.to);
}
}
if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() {
@ -1372,6 +1424,7 @@ fn single_action_reason(action: PhaseAction) -> PhaseReason {
fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest {
MultiphaseRequest {
bounds: request.bounds,
clearance: request.clearance,
windows: request
.windows
.iter()
@ -1686,7 +1739,11 @@ mod tests {
MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy),
));
}
MultiphaseRequest { bounds, windows }
MultiphaseRequest {
bounds,
windows,
clearance: 0,
}
}
fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) {
@ -1739,7 +1796,11 @@ mod tests {
.map(|window| window.from.union(window.to))
.reduce(|bounds, rect| bounds.union(rect))
.unwrap_or_else(|| rect(0, 0, 1, 1));
MultiphaseRequest { bounds, windows }
MultiphaseRequest {
bounds,
windows,
clearance: 0,
}
}
fn actions(plan: &MultiphasePlan) -> Vec<PhaseAction> {
@ -1765,6 +1826,20 @@ mod tests {
strategy: PlanStrategy::SingleAction,
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Vertical,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::HierarchyOrderedScales,
@ -1798,20 +1873,6 @@ mod tests {
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Vertical,
},
reason: MultiphasePlanFailure::NoPattern,
},
]
}
@ -1916,6 +1977,38 @@ mod tests {
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn swap_lanes_respect_requested_clearance() {
let mut req = request(vec![
window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)),
window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)),
]);
req.clearance = 10;
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 45));
assert_eq!(step_to(&plan, 0, id(2)), rect(100, 55, 200, 100));
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn swap_lanes_tolerate_stationary_siblings_in_request() {
let req = request(vec![
window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)),
window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)),
window(3, rect(200, 0, 300, 100), rect(200, 0, 300, 100)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
}
);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn vertical_swap_lanes_follow_motion_direction_not_node_id() {
let req = request(vec![
@ -2232,6 +2325,59 @@ mod tests {
assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400));
}
#[test]
fn stack_extraction_with_resized_moving_child_still_moves_before_growth() {
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, 1, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let req = generated_request(&old, &new, rect(0, 0, 300, 120));
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 120));
assert_eq!(step_to(plan, 0, id(2)), rect(200, 0, 300, 60));
assert_eq!(step_to(plan, 0, id(3)), rect(200, 60, 300, 120));
assert_eq!(step_to(plan, 1, id(2)), rect(100, 0, 200, 60));
assert_eq!(step_to(plan, 2, id(2)), rect(100, 0, 200, 120));
assert_eq!(step_to(plan, 2, id(3)), rect(200, 0, 300, 120));
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn bounded_generated_supported_split_tree_corpus_is_deterministic() {
let mut cases = vec![];
@ -2543,6 +2689,7 @@ mod tests {
fn diagnostics_report_shrink_bound_rejections() {
let req = MultiphaseRequest {
bounds: rect(0, 0, 400, 100),
clearance: 0,
windows: vec![
MultiphaseWindow {
node_id: id(1),

View file

@ -1650,6 +1650,12 @@ impl State {
}
}
fn layout_animation_clearance(&self) -> i32 {
let border = self.theme.sizes.border_width.get().max(0);
let gap = self.theme.sizes.gap.get().max(0);
if gap == 0 { border } else { gap + 2 * border }
}
fn start_multiphase_layout_animation(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
@ -1671,6 +1677,7 @@ impl State {
let request = MultiphaseRequest {
bounds,
windows: request_windows,
clearance: self.layout_animation_clearance(),
};
let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan,