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 ## 9. Mixed-Action Phases
Look for layouts where one window can move on one axis while another window This case is easiest to prove with the planner tests because Wry currently has
scales on a different axis in the same proven phase. 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: Expected:
@ -298,8 +317,8 @@ Expected:
- no individual window moves diagonally - no individual window moves diagonally
- no overlap occurs at any point during the phase - no overlap occurs at any point during the phase
This is easier to confirm with debug fallback logs plus visual inspection. A A fallback here is acceptable if no normal user command can create this geometry;
fallback here is acceptable if the planner cannot prove the sequence. the planner test above is the authority for the mixed-action rule.
## 10. Mono Mode ## 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> { pub fn exit_frames(&self, now_nsec: u64) -> Vec<RetainedExitFrame> {
self.exits self.exits
.borrow() .borrow()
@ -614,6 +628,52 @@ impl PhasedWindowAnimation {
let t = self.curve.sample(t); let t = self.curve.sample(t);
lerp_rect(segment.from, segment.to, 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 { struct ExitAnimation {
@ -863,6 +923,52 @@ mod tests {
assert_eq!(state.visual_rect(id, c, 200_000_000), c); 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] #[test]
fn linear_retarget_interrupts_phased_animation_from_current_rect() { fn linear_retarget_interrupts_phased_animation_from_current_rect() {
let state = AnimationState::default(); 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>> { pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec<Vec<usize>> {
let mut groups = vec![]; let mut groups = vec![];
let mut seen = vec![false; windows.len()]; let mut seen = vec![false; windows.len()];
@ -2378,6 +2424,42 @@ mod tests {
assert!(validate_plan_continuous(&req, plan)); 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] #[test]
fn bounded_generated_supported_split_tree_corpus_is_deterministic() { fn bounded_generated_supported_split_tree_corpus_is_deterministic() {
let mut cases = vec![]; let mut cases = vec![];

View file

@ -7,7 +7,7 @@ use {
expand_damage_rect, expand_damage_rect,
multiphase::{ multiphase::{
MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, 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, spawn_in_start_rect,
}, },
@ -1679,6 +1679,9 @@ impl State {
windows: request_windows, windows: request_windows,
clearance: self.layout_animation_clearance(), 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) { let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan, Ok(plan) => plan,
Err(diagnostic) => { Err(diagnostic) => {
@ -1690,7 +1693,53 @@ impl State {
return false; 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; return false;
} }
let mut entries = vec![]; let mut entries = vec![];
@ -1700,7 +1749,7 @@ impl State {
let mut current = window.from; let mut current = window.from;
let mut damage = current.union(window.to); let mut damage = current.union(window.to);
let mut phases = vec![]; let mut phases = vec![];
for phase in &plan.phases { for phase in plan_phases {
match phase match phase
.steps .steps
.iter() .iter()