1
0
Fork 0
forked from wry/wry

Handle phased animation retargeting

This commit is contained in:
atagen 2026-05-22 16:35:44 +10:00
parent 1a75f47709
commit b211b53528
4 changed files with 263 additions and 7 deletions

View file

@ -288,8 +288,27 @@ Hard failure:
## 9. Mixed-Action Phases
Look for layouts where one window can move on one axis while another window
scales on a different axis in the same proven phase.
This case is easiest to prove with the planner tests because Wry currently has
few user commands that create a same-batch move for one tiled window and an
independent resize for another. The canonical geometry is:
```text
start: A = (0,0)-(80,80) B = (200,0)-(280,80)
target: A = (40,0)-(120,80) B = (200,0)-(280,120)
```
`A` moves horizontally while `B` scales vertically. The windows are far enough
apart that the mixed phase is provably non-overlapping.
To exercise the current proof directly, run:
```sh
cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven
```
For visual/manual testing, use any command sequence that can produce the same
shape in one layout batch: one window changes only x-position, and a separate,
non-overlapping window changes only height.
Expected:
@ -298,8 +317,8 @@ Expected:
- no individual window moves diagonally
- no overlap occurs at any point during the phase
This is easier to confirm with debug fallback logs plus visual inspection. A
fallback here is acceptable if the planner cannot prove the sequence.
A fallback here is acceptable if no normal user command can create this geometry;
the planner test above is the authority for the mixed-action rule.
## 10. Mono Mode

View file

@ -474,6 +474,20 @@ impl AnimationState {
}
}
pub fn phased_route_to(
&self,
node_id: NodeId,
target: Rect,
now_nsec: u64,
) -> Option<Vec<(Rect, Rect)>> {
let phased = self.phased.borrow();
let anim = phased.get(&node_id)?;
if anim.done(now_nsec) {
return None;
}
anim.route_to(target, now_nsec)
}
pub fn exit_frames(&self, now_nsec: u64) -> Vec<RetainedExitFrame> {
self.exits
.borrow()
@ -614,6 +628,52 @@ impl PhasedWindowAnimation {
let t = self.curve.sample(t);
lerp_rect(segment.from, segment.to, t)
}
fn phase_at(&self, now_nsec: u64) -> Option<usize> {
if self.duration_nsec == 0 || self.segments.is_empty() {
return None;
}
let elapsed = now_nsec.saturating_sub(self.start_nsec);
let phase = (elapsed / self.duration_nsec) as usize;
(phase < self.segments.len()).then_some(phase)
}
fn path_points(&self) -> Option<Vec<Rect>> {
let first = self.segments.first()?;
let mut points = vec![first.from];
points.extend(self.segments.iter().map(|segment| segment.to));
Some(points)
}
fn route_to(&self, target: Rect, now_nsec: u64) -> Option<Vec<(Rect, Rect)>> {
let phase = self.phase_at(now_nsec)?;
let points = self.path_points()?;
let target_idx = points.iter().position(|point| *point == target)?;
let current = self.rect_at(now_nsec);
if current == target {
return Some(vec![]);
}
let mut route = vec![];
if target_idx <= phase {
push_non_empty_segment(&mut route, current, points[phase]);
for idx in (target_idx..phase).rev() {
push_non_empty_segment(&mut route, points[idx + 1], points[idx]);
}
} else {
push_non_empty_segment(&mut route, current, points[phase + 1]);
for idx in (phase + 1)..target_idx {
push_non_empty_segment(&mut route, points[idx], points[idx + 1]);
}
}
Some(route)
}
}
fn push_non_empty_segment(route: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) {
if from != to {
route.push((from, to));
}
}
struct ExitAnimation {
@ -863,6 +923,52 @@ mod tests {
assert_eq!(state.visual_rect(id, c, 200_000_000), c);
}
#[test]
fn phased_route_reverses_to_existing_endpoint() {
let state = AnimationState::default();
let id = NodeId(1);
let a = Rect::new_sized_saturating(0, 0, 100, 100);
let b = Rect::new_sized_saturating(0, 0, 100, 50);
let c = Rect::new_sized_saturating(100, 0, 100, 50);
assert!(state.set_phased_target(
id,
vec![(a, b), (b, c)],
None,
0,
100,
AnimationCurve::Linear
));
let current = lerp_rect(b, c, 0.5);
assert_eq!(
state.phased_route_to(id, a, 150_000_000).unwrap(),
vec![(current, b), (b, a)]
);
}
#[test]
fn phased_route_continues_to_existing_endpoint() {
let state = AnimationState::default();
let id = NodeId(1);
let a = Rect::new_sized_saturating(0, 0, 100, 100);
let b = Rect::new_sized_saturating(0, 0, 100, 50);
let c = Rect::new_sized_saturating(100, 0, 100, 50);
assert!(state.set_phased_target(
id,
vec![(a, b), (b, c)],
None,
0,
100,
AnimationCurve::Linear
));
let current = lerp_rect(a, b, 0.5);
assert_eq!(
state.phased_route_to(id, c, 50_000_000).unwrap(),
vec![(current, b), (b, c)]
);
}
#[test]
fn linear_retarget_interrupts_phased_animation_from_current_rect() {
let state = AnimationState::default();

View file

@ -402,6 +402,52 @@ pub fn plan_no_overlap_explained(
}
}
pub(crate) fn validate_phase_paths(
request: &MultiphaseRequest,
paths: &[Vec<(Rect, Rect)>],
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
if paths.len() != request.windows.len() {
return Err(MultiphasePlanFailure::NoPattern);
}
let phase_count = paths.iter().map(Vec::len).max().unwrap_or(0);
if phase_count == 0 {
return Err(MultiphasePlanFailure::NoPattern);
}
let mut phases = vec![];
for phase_idx in 0..phase_count {
let mut steps = vec![];
let mut actions = vec![];
for (window_idx, path) in paths.iter().enumerate() {
let Some((from, to)) = path.get(phase_idx).copied() else {
continue;
};
if from == to {
continue;
}
let step = MultiphaseStep {
node_id: request.windows[window_idx].node_id,
from,
to,
};
let Some(action) = classify_step(step) else {
return Err(MultiphasePlanFailure::NoPattern);
};
steps.push(step);
actions.push(action);
}
if !steps.is_empty() {
phases.push(MultiphasePhase {
action: MultiphasePhaseAction::from_step_actions(actions),
steps,
});
}
}
let plan = MultiphasePlan { phases };
validate_plan_continuous_diagnostic(request, &plan)
.map(|_| plan)
.map_err(MultiphasePlanFailure::Validation)
}
pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec<Vec<usize>> {
let mut groups = vec![];
let mut seen = vec![false; windows.len()];
@ -2378,6 +2424,42 @@ mod tests {
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn validated_phase_paths_accept_interrupted_reverse_route() {
let a_current = rect(50, 0, 150, 50);
let b_current = rect(50, 50, 150, 100);
let req = request(vec![
window(1, a_current, rect(0, 0, 100, 100)),
window(2, b_current, rect(100, 0, 200, 100)),
]);
let paths = vec![
vec![
(a_current, rect(0, 0, 100, 50)),
(rect(0, 0, 100, 50), rect(0, 0, 100, 100)),
],
vec![
(b_current, rect(100, 50, 200, 100)),
(rect(100, 50, 200, 100), rect(100, 0, 200, 100)),
],
];
let plan = validate_phase_paths(&req, &paths).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn bounded_generated_supported_split_tree_corpus_is_deterministic() {
let mut cases = vec![];

View file

@ -7,7 +7,7 @@ use {
expand_damage_rect,
multiphase::{
MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy,
partition_motion_groups, plan_no_overlap_with_diagnostics,
partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths,
},
spawn_in_start_rect,
},
@ -1679,6 +1679,9 @@ impl State {
windows: request_windows,
clearance: self.layout_animation_clearance(),
};
if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) {
return true;
}
let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan,
Err(diagnostic) => {
@ -1690,7 +1693,53 @@ impl State {
return false;
}
};
if plan.phases.is_empty() {
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_existing_phased_retarget(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
request: &MultiphaseRequest,
now_nsec: u64,
) -> bool {
let mut paths = vec![];
for &idx in group {
let candidate = &candidates[idx];
let window = windows[idx];
let Some(path) =
self.animations
.phased_route_to(candidate.node_id, window.to, now_nsec)
else {
return false;
};
paths.push(path);
}
let plan = match validate_phase_paths(request, &paths) {
Ok(plan) => plan,
Err(error) => {
log::debug!(
"existing phased retarget rejected for group {:?}: {:?}",
group,
error
);
return false;
}
};
log::debug!("retargeting active phased animation for group {:?}", group);
self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec)
}
fn start_multiphase_plan(
self: &Rc<Self>,
candidates: &[LayoutAnimationCandidate],
windows: &[MultiphaseWindow],
group: &[usize],
plan_phases: &[crate::animation::multiphase::MultiphasePhase],
now_nsec: u64,
) -> bool {
if plan_phases.is_empty() {
return false;
}
let mut entries = vec![];
@ -1700,7 +1749,7 @@ impl State {
let mut current = window.from;
let mut damage = current.union(window.to);
let mut phases = vec![];
for phase in &plan.phases {
for phase in plan_phases {
match phase
.steps
.iter()