Handle phased animation retargeting
This commit is contained in:
parent
1a75f47709
commit
b211b53528
4 changed files with 263 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
106
src/animation.rs
106
src/animation.rs
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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![];
|
||||||
|
|
|
||||||
55
src/state.rs
55
src/state.rs
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue