1
0
Fork 0
forked from wry/wry
wry/crates/layout-animation/src/lib.rs

3570 lines
113 KiB
Rust

use jay_geometry::Rect;
const CURVE_MAX_POINTS: usize = 33;
const CURVE_FLATNESS_EPSILON: f32 = 0.001;
const CURVE_MAX_DEPTH: u8 = 8;
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum AnimationCurve {
Linear,
Piecewise(PiecewiseCurve),
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum AnimationStyle {
Plain,
Multiphase,
}
impl AnimationStyle {
pub fn from_config(value: u32) -> Option<Self> {
match value {
0 => Some(Self::Plain),
1 => Some(Self::Multiphase),
_ => None,
}
}
}
impl AnimationCurve {
pub fn from_config(value: u32) -> Self {
match value {
0 => Self::Linear,
1 => Self::from_cubic_bezier(0.25, 0.1, 0.25, 1.0).unwrap(),
2 => Self::from_cubic_bezier(0.42, 0.0, 1.0, 1.0).unwrap(),
4 => Self::from_cubic_bezier(0.42, 0.0, 0.58, 1.0).unwrap(),
_ => Self::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(),
}
}
pub fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Option<Self> {
if !x1.is_finite()
|| !y1.is_finite()
|| !x2.is_finite()
|| !y2.is_finite()
|| !(0.0..=1.0).contains(&x1)
|| !(0.0..=1.0).contains(&x2)
{
return None;
}
Some(Self::Piecewise(PiecewiseCurve::from_cubic_bezier(
x1, y1, x2, y2,
)))
}
pub fn sample(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::Piecewise(curve) => curve.sample(t as f32) as f64,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PiecewiseCurve {
len: u8,
points: [CurvePoint; CURVE_MAX_POINTS],
}
impl PiecewiseCurve {
fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
let mut points = Vec::with_capacity(CURVE_MAX_POINTS);
let p0 = cubic_bezier_point(x1, y1, x2, y2, 0.0);
let p1 = cubic_bezier_point(x1, y1, x2, y2, 1.0);
points.push(p0);
flatten_cubic_bezier(&mut points, (x1, y1, x2, y2), 0.0, p0, 1.0, p1, 0);
let mut array = [CurvePoint::default(); CURVE_MAX_POINTS];
let len = points.len().min(CURVE_MAX_POINTS);
array[..len].copy_from_slice(&points[..len]);
Self {
len: len as u8,
points: array,
}
}
fn sample(self, x: f32) -> f32 {
let len = self.len as usize;
if len <= 1 {
return x;
}
let points = &self.points[..len];
if x <= points[0].x {
return points[0].y;
}
if x >= points[len - 1].x {
return points[len - 1].y;
}
let mut lo = 0;
let mut hi = len - 1;
while lo + 1 < hi {
let mid = (lo + hi) / 2;
if points[mid].x <= x {
lo = mid;
} else {
hi = mid;
}
}
let from = points[lo];
let to = points[hi];
if to.x <= from.x {
return to.y;
}
let t = (x - from.x) / (to.x - from.x);
from.y + (to.y - from.y) * t
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
struct CurvePoint {
x: f32,
y: f32,
}
fn flatten_cubic_bezier(
points: &mut Vec<CurvePoint>,
controls: (f32, f32, f32, f32),
t0: f32,
p0: CurvePoint,
t1: f32,
p1: CurvePoint,
depth: u8,
) {
let tm = (t0 + t1) * 0.5;
let pm = cubic_bezier_point(controls.0, controls.1, controls.2, controls.3, tm);
let projected_y = if p1.x <= p0.x {
(p0.y + p1.y) * 0.5
} else {
let tx = (pm.x - p0.x) / (p1.x - p0.x);
p0.y + (p1.y - p0.y) * tx
};
if (pm.y - projected_y).abs() > CURVE_FLATNESS_EPSILON
&& depth < CURVE_MAX_DEPTH
&& points.len() + 2 < CURVE_MAX_POINTS
{
flatten_cubic_bezier(points, controls, t0, p0, tm, pm, depth + 1);
flatten_cubic_bezier(points, controls, tm, pm, t1, p1, depth + 1);
} else {
points.push(p1);
}
}
fn cubic_bezier_point(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> CurvePoint {
fn bezier(a: f32, b: f32, t: f32) -> f32 {
let inv = 1.0 - t;
3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t
}
CurvePoint {
x: bezier(x1, x2, t),
y: bezier(y1, y2, t),
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct NodeId(pub u32);
const MIN_SHRINK_DENOMINATOR: i32 = 8;
// Integer split remainders can make swapped siblings differ by one pixel. Do
// not spend a full animation phase on that imperceptible bookkeeping step.
const SWAP_AXIS_SNAP_PX: i32 = 1;
#[derive(Clone, Debug)]
pub struct MultiphaseRequest {
pub bounds: Rect,
pub windows: Vec<MultiphaseWindow>,
pub clearance: i32,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct MultiphaseWindow {
pub node_id: NodeId,
pub from: Rect,
pub to: Rect,
pub hierarchy: MultiphaseWindowHierarchy,
}
impl MultiphaseWindow {
pub fn new(node_id: impl Into<NodeId>, from: Rect, to: Rect) -> Self {
Self {
node_id: node_id.into(),
from,
to,
hierarchy: Default::default(),
}
}
pub fn with_hierarchy(
node_id: impl Into<NodeId>,
from: Rect,
to: Rect,
hierarchy: MultiphaseWindowHierarchy,
) -> Self {
Self {
node_id: node_id.into(),
from,
to,
hierarchy,
}
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct MultiphaseWindowHierarchy {
pub source: MultiphaseHierarchyPosition,
pub target: MultiphaseHierarchyPosition,
pub transition: MultiphaseHierarchyTransition,
}
impl MultiphaseWindowHierarchy {
pub fn new(source: MultiphaseHierarchyPosition, target: MultiphaseHierarchyPosition) -> Self {
let transition = if !source.parent_is_mono && target.parent_is_mono {
MultiphaseHierarchyTransition::EnteringMono
} else if source.parent_is_mono && !target.parent_is_mono {
MultiphaseHierarchyTransition::ExitingMono
} else if source.parent.is_none() || target.parent.is_none() {
MultiphaseHierarchyTransition::Unknown
} else if target.depth < source.depth {
MultiphaseHierarchyTransition::Ascending
} else if target.depth > source.depth {
MultiphaseHierarchyTransition::Descending
} else {
MultiphaseHierarchyTransition::SameLevel
};
Self {
source,
target,
transition,
}
}
fn reversed(self) -> Self {
Self {
source: self.target,
target: self.source,
transition: self.transition.reversed(),
}
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub struct MultiphaseHierarchyPosition {
pub parent: Option<NodeId>,
pub depth: u16,
pub sibling_index: Option<u16>,
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 mono_active: bool,
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
pub enum MultiphaseHierarchyTransition {
#[default]
Unknown,
SameLevel,
Ascending,
Descending,
EnteringMono,
ExitingMono,
}
impl MultiphaseHierarchyTransition {
fn reversed(self) -> Self {
match self {
Self::Unknown => Self::Unknown,
Self::SameLevel => Self::SameLevel,
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
Self::EnteringMono => Self::ExitingMono,
Self::ExitingMono => Self::EnteringMono,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlan {
pub phases: Vec<MultiphasePhase>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanned {
pub plan: MultiphasePlan,
pub explanation: MultiphasePlanExplanation,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanExplanation {
pub strategy: PlanStrategy,
pub phases: Vec<PhaseExplanation>,
pub validation: ValidationExplanation,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PhaseExplanation {
pub action: MultiphasePhaseAction,
pub reason: PhaseReason,
pub nodes: Vec<NodeId>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ValidationExplanation {
pub continuous_overlap_passed: bool,
pub final_rects_matched: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlanStrategy {
NoOp,
SingleAction,
MixedSinglePhase,
HierarchyOrderedScales,
OrientationChange { from_axis: PhaseAxis },
SwapLanes { axis: PhaseAxis },
SpaceThenOrthogonalGrowth { axis: PhaseAxis },
ReversedForwardPlan { original: Box<PlanStrategy> },
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PlanDirection {
Forward,
Reverse,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RejectedStrategy {
pub direction: PlanDirection,
pub strategy: PlanStrategy,
pub reason: MultiphasePlanFailure,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseReason {
SingleAction,
SameAxisRedistribution,
MixedAxisActions,
ShrinkIntoLanes {
lane_axis: PhaseAxis,
},
MoveThroughFreedSpace,
GrowOutOfLanes,
CreateSpaceForAscendingChild,
MoveAscendingChildAfterSpaceExists,
OrthogonalGrowthAfterMove,
ParentAxisBeforeChildAxis {
parent_axis: PhaseAxis,
parent_depth: u16,
child_axis: PhaseAxis,
child_depth: u16,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePhase {
pub action: MultiphasePhaseAction,
pub steps: Vec<MultiphaseStep>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MultiphasePhaseAction {
Uniform(PhaseAction),
Mixed(Vec<PhaseAction>),
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct MultiphaseStep {
pub node_id: NodeId,
pub from: Rect,
pub to: Rect,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct PhaseAction {
pub kind: PhaseKind,
pub axis: PhaseAxis,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseKind {
Move,
Scale,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseAxis {
Horizontal,
Vertical,
}
impl MultiphasePhaseAction {
fn from_step_actions(actions: Vec<PhaseAction>) -> Self {
debug_assert!(!actions.is_empty());
let first = actions[0];
if actions.iter().all(|action| *action == first) {
Self::Uniform(first)
} else {
Self::Mixed(actions)
}
}
fn action_for_step(&self, idx: usize) -> Option<PhaseAction> {
match self {
Self::Uniform(action) => Some(*action),
Self::Mixed(actions) => actions.get(idx).copied(),
}
}
#[cfg_attr(not(test), expect(dead_code))]
fn as_uniform(&self) -> Option<PhaseAction> {
match self {
Self::Uniform(action) => Some(*action),
Self::Mixed(_) => None,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphaseError {
EmptyBounds,
EmptyWindow,
DuplicateWindow,
InitialOverlap,
FinalOverlap,
NoPlan,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanDiagnostic {
pub forward: MultiphasePlanFailure,
pub reverse: Option<MultiphasePlanFailure>,
pub attempted: Vec<RejectedStrategy>,
}
impl MultiphasePlanDiagnostic {
fn legacy_error(self) -> MultiphaseError {
match self.forward {
MultiphasePlanFailure::Request(error) => error,
_ => MultiphaseError::NoPlan,
}
}
}
impl ValidationExplanation {
fn passed() -> Self {
Self {
continuous_overlap_passed: true,
final_rects_matched: true,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphasePlanFailure {
Request(MultiphaseError),
NoPattern,
ShrinkBound {
axis: PhaseAxis,
available: i32,
required: i32,
},
InvalidPhaseStep {
action: PhaseAction,
node_id: NodeId,
},
Validation(MultiphaseValidationError),
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphaseValidationError {
DuplicatePhaseStep {
phase: usize,
node_id: NodeId,
},
PhaseActionCount {
phase: usize,
actions: usize,
steps: usize,
},
UnknownPhaseStep {
phase: usize,
node_id: NodeId,
},
StaleStepStart {
phase: usize,
node_id: NodeId,
},
PhaseOverlap {
phase: usize,
a: NodeId,
b: NodeId,
},
FinalMismatch {
node_id: NodeId,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct PlanForwardFailure {
reason: MultiphasePlanFailure,
attempted: Vec<RejectedStrategy>,
}
pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphaseError> {
plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error())
}
pub fn plan_no_overlap_with_diagnostics(
request: &MultiphaseRequest,
) -> Result<MultiphasePlan, MultiphasePlanDiagnostic> {
plan_no_overlap_explained(request).map(|planned| planned.plan)
}
pub fn plan_no_overlap_explained(
request: &MultiphaseRequest,
) -> Result<MultiphasePlanned, MultiphasePlanDiagnostic> {
if let Err(error) = validate_request(request) {
return Err(MultiphasePlanDiagnostic {
forward: MultiphasePlanFailure::Request(error),
reverse: None,
attempted: vec![],
});
}
if request
.windows
.iter()
.all(|window| window.from == window.to)
{
return Ok(MultiphasePlanned {
plan: MultiphasePlan { phases: vec![] },
explanation: MultiphasePlanExplanation {
strategy: PlanStrategy::NoOp,
phases: vec![],
validation: ValidationExplanation::passed(),
},
});
}
if let Some(failure) = target_shrink_bound_failure(request) {
return Err(MultiphasePlanDiagnostic {
forward: failure,
reverse: None,
attempted: vec![],
});
}
let forward = match plan_forward(request, PlanDirection::Forward) {
Ok(plan) => return Ok(plan),
Err(error) => error,
};
let reversed = reverse_request(request);
match plan_forward(&reversed, PlanDirection::Reverse) {
Ok(plan) => Ok(reverse_planned(plan)),
Err(reverse) => {
let mut attempted = forward.attempted;
attempted.extend(reverse.attempted);
Err(MultiphasePlanDiagnostic {
forward: forward.reason,
reverse: Some(reverse.reason),
attempted,
})
}
}
}
pub 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 fn partition_motion_groups(
windows: &[MultiphaseWindow],
clearance: i32,
) -> Vec<Vec<usize>> {
let clearance = clearance.max(0);
let mut groups = vec![];
let mut seen = vec![false; windows.len()];
for start in 0..windows.len() {
if seen[start] {
continue;
}
seen[start] = true;
let mut group = vec![];
let mut pending = vec![start];
while let Some(idx) = pending.pop() {
group.push(idx);
let bounds = motion_bounds_with_clearance(windows[idx], clearance);
for other in 0..windows.len() {
if seen[other]
|| !bounds.intersects(&motion_bounds_with_clearance(windows[other], clearance))
{
continue;
}
seen[other] = true;
pending.push(other);
}
}
group.sort_unstable();
groups.push(group);
}
groups
}
fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> {
if request.bounds.is_empty() {
return Err(MultiphaseError::EmptyBounds);
}
for (idx, window) in request.windows.iter().enumerate() {
if window.from.is_empty() || window.to.is_empty() {
return Err(MultiphaseError::EmptyWindow);
}
for other in &request.windows[..idx] {
if other.node_id == window.node_id {
return Err(MultiphaseError::DuplicateWindow);
}
}
}
if overlaps(request.windows.iter().map(|window| window.from)) {
return Err(MultiphaseError::InitialOverlap);
}
if overlaps(request.windows.iter().map(|window| window.to)) {
return Err(MultiphaseError::FinalOverlap);
}
Ok(())
}
fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option<MultiphasePlanFailure> {
let min_width = sane_min_size(request.bounds.width());
let min_height = sane_min_size(request.bounds.height());
for window in &request.windows {
if window.to.width() < min_width {
return Some(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Horizontal,
available: window.to.width(),
required: min_width,
});
}
if window.to.height() < min_height {
return Some(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Vertical,
available: window.to.height(),
required: min_height,
});
}
}
None
}
fn plan_forward(
request: &MultiphaseRequest,
direction: PlanDirection,
) -> Result<MultiphasePlanned, PlanForwardFailure> {
let mut rejection = None;
let mut attempted = vec![];
match plan_single_action_phase(request) {
Ok(plan) => return Ok(plan),
Err(error) => {
record_rejection(&mut attempted, direction, PlanStrategy::SingleAction, error);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
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) => {
record_rejection(
&mut attempted,
direction,
PlanStrategy::HierarchyOrderedScales,
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_orientation_change(request, axis) {
Ok(plan) => return Ok(plan),
Err(error) => {
record_rejection(
&mut attempted,
direction,
PlanStrategy::OrientationChange { from_axis: axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_axis_crossing_lanes(request, axis) {
Ok(plan) => return Ok(plan),
Err(error) => {
record_rejection(
&mut attempted,
direction,
PlanStrategy::SwapLanes { axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
Err(PlanForwardFailure {
reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern),
attempted,
})
}
fn record_rejection(
attempted: &mut Vec<RejectedStrategy>,
direction: PlanDirection,
strategy: PlanStrategy,
reason: MultiphasePlanFailure,
) {
attempted.push(RejectedStrategy {
direction,
strategy,
reason,
});
}
fn plan_single_action_phase(
request: &MultiphaseRequest,
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
let mut uniform_action = None;
let mut is_uniform = true;
let mut steps = vec![];
let mut step_actions = vec![];
for window in &request.windows {
if window.from == window.to {
continue;
}
let step = MultiphaseStep {
node_id: window.node_id,
from: window.from,
to: window.to,
};
let Some(step_action) = classify_step(step) else {
return Err(MultiphasePlanFailure::NoPattern);
};
if step_action.kind == PhaseKind::Scale {
let (available, required) = match step_action.axis {
PhaseAxis::Horizontal => (window.to.width(), sane_min_size(request.bounds.width())),
PhaseAxis::Vertical => (window.to.height(), sane_min_size(request.bounds.height())),
};
if available < required {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: step_action.axis,
available,
required,
});
}
}
if uniform_action.is_some_and(|action| action != step_action) {
is_uniform = false;
}
uniform_action.get_or_insert(step_action);
steps.push(step);
step_actions.push(step_action);
}
if steps.is_empty() {
return Err(MultiphasePlanFailure::NoPattern);
}
if !is_uniform {
return build_validated_plan(
request,
PlanStrategy::MixedSinglePhase,
[phase_draft_mixed(
steps,
step_actions,
PhaseReason::MixedAxisActions,
)],
);
}
let action = uniform_action.unwrap();
build_validated_plan(
request,
PlanStrategy::SingleAction,
[phase_draft_uniform(
action,
steps,
single_action_reason(action),
)],
)
}
fn plan_hierarchy_ordered_axis_scales(
request: &MultiphaseRequest,
) -> Result<MultiphasePlanned, 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 order = 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![];
let reason = PhaseReason::ParentAxisBeforeChildAxis {
parent_axis: order.axes[0],
parent_depth: order.depths[0],
child_axis: order.axes[1],
child_depth: order.depths[1],
};
for axis in order.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(phase_draft(PhaseKind::Scale, axis, steps, reason));
}
let [first, second] = phases
.try_into()
.map_err(|_| MultiphasePlanFailure::NoPattern)?;
build_validated_plan(
request,
PlanStrategy::HierarchyOrderedScales,
[first, second],
)
}
fn hierarchy_scale_axis_order(
request: &MultiphaseRequest,
first_axis: PhaseAxis,
second_axis: PhaseAxis,
) -> Option<HierarchyScaleAxisOrder> {
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(HierarchyScaleAxisOrder {
axes: [first_axis, second_axis],
depths: [first_priority, second_priority],
}),
std::cmp::Ordering::Greater => Some(HierarchyScaleAxisOrder {
axes: [second_axis, first_axis],
depths: [second_priority, first_priority],
}),
std::cmp::Ordering::Equal => None,
}
}
#[derive(Copy, Clone)]
struct HierarchyScaleAxisOrder {
axes: [PhaseAxis; 2],
depths: [u16; 2],
}
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(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
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
.windows
.iter()
.map(|window| orth_start(window.from, axis))
.min()
.ok_or(MultiphasePlanFailure::NoPattern)?;
let orth_max = request
.windows
.iter()
.map(|window| orth_end(window.from, axis))
.max()
.ok_or(MultiphasePlanFailure::NoPattern)?;
if moving_windows.iter().any(|window| {
orth_start(window.from, axis) != orth_min
|| orth_end(window.from, axis) != orth_max
|| orth_start(window.to, axis) != orth_min
|| orth_end(window.to, axis) != orth_max
|| main_start(window.from, axis) == main_start(window.to, axis)
}) {
return Err(MultiphasePlanFailure::NoPattern);
}
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 {
axis: axis.other(),
available: lane_size,
required,
});
}
let mut windows = moving_windows;
windows.sort_by_key(|window| lane_sort_key(*window, axis));
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
let mut phase4 = vec![];
let mut lane_start = orth_min;
for (idx, window) in windows.iter().enumerate() {
let extra = if lane_remainder > 0 {
lane_remainder -= 1;
1
} else {
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,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
);
let mut lane_move = crossing_lane_move_rect(lane_from, window.to, axis);
if same_axis_delta_within(lane_move, lane_to, axis, SWAP_AXIS_SNAP_PX) {
lane_move = lane_to;
}
push_step(&mut phase1, window.node_id, window.from, lane_from);
push_step(&mut phase2, window.node_id, lane_from, lane_move);
push_step(&mut phase3, window.node_id, lane_move, lane_to);
push_step(&mut phase4, window.node_id, lane_to, window.to);
if idx + 1 < windows.len() {
lane_start = lane_end + clearance;
}
}
build_validated_plan(
request,
PlanStrategy::SwapLanes { axis },
[
phase_draft(
PhaseKind::Scale,
axis.other(),
phase1,
PhaseReason::ShrinkIntoLanes {
lane_axis: axis.other(),
},
),
phase_draft_classified(
phase2,
PhaseReason::MoveThroughFreedSpace,
)?,
phase_draft(
PhaseKind::Scale,
axis,
phase3,
PhaseReason::SameAxisRedistribution,
),
phase_draft(
PhaseKind::Scale,
axis.other(),
phase4,
PhaseReason::GrowOutOfLanes,
),
],
)
}
fn phase_draft_classified(
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
) -> Result<MultiphasePhaseDraft, MultiphasePlanFailure> {
let actions = steps
.iter()
.map(|step| classify_step(*step).ok_or(MultiphasePlanFailure::NoPattern))
.collect::<Result<Vec<_>, _>>()?;
Ok(phase_draft_mixed(steps, actions, reason))
}
fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect {
let size = main_size(from, axis);
if main_start(target, axis) > main_start(from, axis) {
let end = main_end(target, axis);
with_main_interval(from, axis, end - size, end)
} else {
let start = main_start(target, axis);
with_main_interval(from, axis, start, start + size)
}
}
fn same_axis_delta_within(from: Rect, to: Rect, axis: PhaseAxis, max_delta: i32) -> bool {
let start_delta = (main_start(from, axis) - main_start(to, axis)).abs();
let end_delta = (main_end(from, axis) - main_end(to, axis)).abs();
start_delta.max(end_delta) <= max_delta
}
fn lane_sort_key(window: MultiphaseWindow, axis: PhaseAxis) -> (usize, i32, i32, u32) {
let delta = main_start(window.to, axis) - main_start(window.from, axis);
let direction = match delta.cmp(&0) {
std::cmp::Ordering::Greater => 0,
std::cmp::Ordering::Less => 1,
std::cmp::Ordering::Equal => 2,
};
(
direction,
main_start(window.from, axis),
main_start(window.to, axis),
window.node_id.0,
)
}
fn plan_space_then_orthogonal_growth(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
if request.windows.len() < 2 {
return Err(MultiphasePlanFailure::NoPattern);
}
let orth_axis = axis.other();
let min_width = sane_min_size(request.bounds.width());
let min_height = sane_min_size(request.bounds.height());
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
for window in &request.windows {
if window.to.width() < min_width {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Horizontal,
available: window.to.width(),
required: min_width,
});
}
if window.to.height() < min_height {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Vertical,
available: window.to.height(),
required: min_height,
});
}
let main_changes = main_start(window.from, axis) != main_start(window.to, axis)
|| 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,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
);
push_step(&mut phase2, window.node_id, window.from, after_move);
orth_from = after_move;
} else if main_changes {
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);
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;
}
}
if orth_changes {
push_step(&mut phase3, window.node_id, orth_from, window.to);
}
}
if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() {
return Err(MultiphasePlanFailure::NoPattern);
}
build_validated_plan(
request,
PlanStrategy::SpaceThenOrthogonalGrowth { axis },
[
phase_draft(
PhaseKind::Scale,
axis,
phase1,
PhaseReason::CreateSpaceForAscendingChild,
),
phase_draft(
PhaseKind::Move,
axis,
phase2,
PhaseReason::MoveAscendingChildAfterSpaceExists,
),
phase_draft(
PhaseKind::Scale,
orth_axis,
phase3,
PhaseReason::OrthogonalGrowthAfterMove,
),
],
)
}
fn plan_orientation_change(
request: &MultiphaseRequest,
from_axis: PhaseAxis,
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
if request.windows.len() < 2 {
return Err(MultiphasePlanFailure::NoPattern);
}
let to_axis = from_axis.other();
let min_lane_size = sane_min_size(main_size(request.bounds, to_axis));
let target_start = request
.windows
.first()
.map(|window| main_start(window.to, from_axis))
.ok_or(MultiphasePlanFailure::NoPattern)?;
let target_end = request
.windows
.first()
.map(|window| main_end(window.to, from_axis))
.ok_or(MultiphasePlanFailure::NoPattern)?;
let source_start = request
.windows
.first()
.map(|window| main_start(window.from, to_axis))
.ok_or(MultiphasePlanFailure::NoPattern)?;
let source_end = request
.windows
.first()
.map(|window| main_end(window.from, to_axis))
.ok_or(MultiphasePlanFailure::NoPattern)?;
if request.windows.iter().any(|window| {
main_start(window.from, to_axis) != source_start
|| main_end(window.from, to_axis) != source_end
|| main_start(window.to, from_axis) != target_start
|| main_end(window.to, from_axis) != target_end
|| main_size(window.to, to_axis) < min_lane_size
}) {
return Err(MultiphasePlanFailure::NoPattern);
}
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
for window in &request.windows {
let lane = with_main_interval(
window.from,
to_axis,
main_start(window.to, to_axis),
main_end(window.to, to_axis),
);
let moved = with_main_interval(
lane,
from_axis,
main_start(window.to, from_axis),
main_start(window.to, from_axis) + main_size(lane, from_axis),
);
push_step(&mut phase1, window.node_id, window.from, lane);
push_step(&mut phase2, window.node_id, lane, moved);
push_step(&mut phase3, window.node_id, moved, window.to);
}
if phase1.is_empty() || phase3.is_empty() {
return Err(MultiphasePlanFailure::NoPattern);
}
build_validated_plan(
request,
PlanStrategy::OrientationChange { from_axis },
[
phase_draft(
PhaseKind::Scale,
to_axis,
phase1,
PhaseReason::ShrinkIntoLanes { lane_axis: to_axis },
),
phase_draft(
PhaseKind::Move,
from_axis,
phase2,
PhaseReason::MoveThroughFreedSpace,
),
phase_draft(
PhaseKind::Scale,
from_axis,
phase3,
PhaseReason::GrowOutOfLanes,
),
],
)
}
struct MultiphasePhaseDraft {
action: MultiphasePhaseActionDraft,
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
}
enum MultiphasePhaseActionDraft {
Uniform(PhaseAction),
Mixed(Vec<PhaseAction>),
}
fn phase_draft_uniform(
action: PhaseAction,
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
) -> MultiphasePhaseDraft {
MultiphasePhaseDraft {
action: MultiphasePhaseActionDraft::Uniform(action),
steps,
reason,
}
}
fn phase_draft(
kind: PhaseKind,
axis: PhaseAxis,
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
) -> MultiphasePhaseDraft {
phase_draft_uniform(PhaseAction { kind, axis }, steps, reason)
}
fn phase_draft_mixed(
steps: Vec<MultiphaseStep>,
actions: Vec<PhaseAction>,
reason: PhaseReason,
) -> MultiphasePhaseDraft {
MultiphasePhaseDraft {
action: MultiphasePhaseActionDraft::Mixed(actions),
steps,
reason,
}
}
fn build_validated_plan<const N: usize>(
request: &MultiphaseRequest,
strategy: PlanStrategy,
phases: [MultiphasePhaseDraft; N],
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
let mut explanations = vec![];
let phases: Vec<_> = phases
.into_iter()
.filter_map(|draft| {
if draft.steps.is_empty() {
return None;
}
let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect();
nodes.sort_by_key(|node_id| node_id.0);
let action = match draft.action {
MultiphasePhaseActionDraft::Uniform(action) => {
MultiphasePhaseAction::Uniform(action)
}
MultiphasePhaseActionDraft::Mixed(actions) => {
debug_assert_eq!(actions.len(), draft.steps.len());
MultiphasePhaseAction::from_step_actions(actions)
}
};
explanations.push(PhaseExplanation {
action: action.clone(),
reason: draft.reason,
nodes,
});
Some(MultiphasePhase {
action,
steps: draft.steps,
})
})
.collect();
for phase in &phases {
for (idx, step) in phase.steps.iter().enumerate() {
let action = phase.action.action_for_step(idx).unwrap();
if classify_step(*step) != Some(action) {
return Err(MultiphasePlanFailure::InvalidPhaseStep {
action,
node_id: step.node_id,
});
}
}
}
let plan = MultiphasePlan { phases };
validate_plan_continuous_diagnostic(request, &plan)
.map(|_| MultiphasePlanned {
plan,
explanation: MultiphasePlanExplanation {
strategy,
phases: explanations,
validation: ValidationExplanation::passed(),
},
})
.map_err(MultiphasePlanFailure::Validation)
}
#[cfg_attr(not(test), expect(dead_code))]
fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool {
validate_plan_continuous_diagnostic(request, plan).is_ok()
}
fn validate_plan_continuous_diagnostic(
request: &MultiphaseRequest,
plan: &MultiphasePlan,
) -> Result<(), MultiphaseValidationError> {
let mut current: Vec<_> = request
.windows
.iter()
.map(|window| (window.node_id, window.from))
.collect();
for (phase_idx, phase) in plan.phases.iter().enumerate() {
if let MultiphasePhaseAction::Mixed(actions) = &phase.action
&& actions.len() != phase.steps.len()
{
return Err(MultiphaseValidationError::PhaseActionCount {
phase: phase_idx,
actions: actions.len(),
steps: phase.steps.len(),
});
}
for (idx, step) in phase.steps.iter().enumerate() {
if phase.steps[..idx]
.iter()
.any(|prev| prev.node_id == step.node_id)
{
return Err(MultiphaseValidationError::DuplicatePhaseStep {
phase: phase_idx,
node_id: step.node_id,
});
}
let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id)
else {
return Err(MultiphaseValidationError::UnknownPhaseStep {
phase: phase_idx,
node_id: step.node_id,
});
};
if *rect != step.from {
return Err(MultiphaseValidationError::StaleStepStart {
phase: phase_idx,
node_id: step.node_id,
});
}
}
let motions: Vec<_> = current
.iter()
.map(|(node_id, rect)| {
let to = phase
.steps
.iter()
.find(|step| step.node_id == *node_id)
.map(|step| step.to)
.unwrap_or(*rect);
RectMotion { from: *rect, to }
})
.collect();
for (idx, motion) in motions.iter().enumerate() {
if let Some((other_idx, _)) = motions[idx + 1..]
.iter()
.enumerate()
.find(|(_, other)| motions_overlap_during_phase(*motion, **other))
{
return Err(MultiphaseValidationError::PhaseOverlap {
phase: phase_idx,
a: current[idx].0,
b: current[idx + 1 + other_idx].0,
});
}
}
for step in &phase.steps {
let (_, rect) = current
.iter_mut()
.find(|(node_id, _)| *node_id == step.node_id)
.unwrap();
*rect = step.to;
}
}
for window in &request.windows {
if !current
.iter()
.find(|(node_id, _)| *node_id == window.node_id)
.is_some_and(|(_, rect)| *rect == window.to)
{
return Err(MultiphaseValidationError::FinalMismatch {
node_id: window.node_id,
});
}
}
Ok(())
}
#[derive(Copy, Clone)]
struct RectMotion {
from: Rect,
to: Rect,
}
fn motions_overlap_during_phase(a: RectMotion, b: RectMotion) -> bool {
let mut interval = TimeInterval::unit();
interval.intersect_less_than(edge_delta(a.from.x1(), a.to.x1(), b.from.x2(), b.to.x2()))
&& interval.intersect_less_than(edge_delta(b.from.x1(), b.to.x1(), a.from.x2(), a.to.x2()))
&& interval.intersect_less_than(edge_delta(a.from.y1(), a.to.y1(), b.from.y2(), b.to.y2()))
&& interval.intersect_less_than(edge_delta(b.from.y1(), b.to.y1(), a.from.y2(), a.to.y2()))
&& interval.is_non_empty()
}
fn edge_delta(a0: i32, a1: i32, b0: i32, b1: i32) -> LinearDelta {
let from = a0 as i64 - b0 as i64;
let to = a1 as i64 - b1 as i64;
LinearDelta {
start: from,
velocity: to - from,
}
}
#[derive(Copy, Clone)]
struct LinearDelta {
start: i64,
velocity: i64,
}
#[derive(Copy, Clone)]
struct TimeInterval {
lower: Rational,
lower_open: bool,
upper: Rational,
upper_open: bool,
}
impl TimeInterval {
fn unit() -> Self {
Self {
lower: Rational::new(0, 1),
lower_open: false,
upper: Rational::new(1, 1),
upper_open: false,
}
}
fn intersect_less_than(&mut self, delta: LinearDelta) -> bool {
if delta.velocity == 0 {
return delta.start < 0;
}
let boundary = Rational::new(-delta.start, delta.velocity);
if delta.velocity > 0 {
self.tighten_upper(boundary, true);
} else {
self.tighten_lower(boundary, true);
}
self.is_non_empty()
}
fn tighten_lower(&mut self, value: Rational, open: bool) {
match value.cmp(&self.lower) {
std::cmp::Ordering::Greater => {
self.lower = value;
self.lower_open = open;
}
std::cmp::Ordering::Equal => {
self.lower_open |= open;
}
std::cmp::Ordering::Less => {}
}
}
fn tighten_upper(&mut self, value: Rational, open: bool) {
match value.cmp(&self.upper) {
std::cmp::Ordering::Less => {
self.upper = value;
self.upper_open = open;
}
std::cmp::Ordering::Equal => {
self.upper_open |= open;
}
std::cmp::Ordering::Greater => {}
}
}
fn is_non_empty(&self) -> bool {
match self.lower.cmp(&self.upper) {
std::cmp::Ordering::Less => true,
std::cmp::Ordering::Equal => !self.lower_open && !self.upper_open,
std::cmp::Ordering::Greater => false,
}
}
}
#[derive(Copy, Clone, Eq, PartialEq)]
struct Rational {
num: i64,
den: i64,
}
impl Rational {
fn new(mut num: i64, mut den: i64) -> Self {
if den < 0 {
num = -num;
den = -den;
}
Self { num, den }
}
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(self.num as i128 * other.den as i128).cmp(&(other.num as i128 * self.den as i128))
}
}
fn classify_step(step: MultiphaseStep) -> Option<PhaseAction> {
let same_x = step.from.x1() == step.to.x1() && step.from.x2() == step.to.x2();
let same_y = step.from.y1() == step.to.y1() && step.from.y2() == step.to.y2();
let same_size = step.from.size() == step.to.size();
match (same_x, same_y, same_size) {
(false, true, true) => Some(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}),
(true, false, true) => Some(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Vertical,
}),
(false, true, false) => Some(PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
}),
(true, false, false) => Some(PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
}),
_ => None,
}
}
fn single_action_reason(action: PhaseAction) -> PhaseReason {
match action.kind {
PhaseKind::Move => PhaseReason::SingleAction,
PhaseKind::Scale => PhaseReason::SameAxisRedistribution,
}
}
fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest {
MultiphaseRequest {
bounds: request.bounds,
clearance: request.clearance,
windows: request
.windows
.iter()
.map(|window| MultiphaseWindow {
node_id: window.node_id,
from: window.to,
to: window.from,
hierarchy: window.hierarchy.reversed(),
})
.collect(),
}
}
fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan {
MultiphasePlan {
phases: plan
.phases
.into_iter()
.rev()
.map(|phase| MultiphasePhase {
action: phase.action,
steps: phase
.steps
.into_iter()
.map(|step| MultiphaseStep {
node_id: step.node_id,
from: step.to,
to: step.from,
})
.collect(),
})
.collect(),
}
}
fn reverse_planned(planned: MultiphasePlanned) -> MultiphasePlanned {
let mut phases = planned.explanation.phases;
phases.reverse();
MultiphasePlanned {
plan: reverse_plan(planned.plan),
explanation: MultiphasePlanExplanation {
strategy: PlanStrategy::ReversedForwardPlan {
original: Box::new(planned.explanation.strategy),
},
phases,
validation: planned.explanation.validation,
},
}
}
fn overlaps(rects: impl IntoIterator<Item = Rect>) -> bool {
let rects: Vec<_> = rects.into_iter().collect();
for (idx, rect) in rects.iter().enumerate() {
if rects[idx + 1..].iter().any(|other| rect.intersects(other)) {
return true;
}
}
false
}
fn motion_bounds(window: MultiphaseWindow) -> Rect {
window.from.union(window.to)
}
fn motion_bounds_with_clearance(window: MultiphaseWindow, clearance: i32) -> Rect {
let bounds = motion_bounds(window);
Rect::new_saturating(
bounds.x1().saturating_sub(clearance),
bounds.y1().saturating_sub(clearance),
bounds.x2().saturating_add(clearance),
bounds.y2().saturating_add(clearance),
)
}
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) {
if from != to {
steps.push(MultiphaseStep { node_id, from, to });
}
}
fn sane_min_size(size: i32) -> i32 {
(size / MIN_SHRINK_DENOMINATOR).max(1)
}
fn main_start(rect: Rect, axis: PhaseAxis) -> i32 {
match axis {
PhaseAxis::Horizontal => rect.x1(),
PhaseAxis::Vertical => rect.y1(),
}
}
fn main_end(rect: Rect, axis: PhaseAxis) -> i32 {
match axis {
PhaseAxis::Horizontal => rect.x2(),
PhaseAxis::Vertical => rect.y2(),
}
}
fn main_size(rect: Rect, axis: PhaseAxis) -> i32 {
main_end(rect, axis) - main_start(rect, axis)
}
fn orth_start(rect: Rect, axis: PhaseAxis) -> i32 {
main_start(rect, axis.other())
}
fn orth_end(rect: Rect, axis: PhaseAxis) -> i32 {
main_end(rect, axis.other())
}
fn with_main_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect {
match axis {
PhaseAxis::Horizontal => Rect::new_saturating(start, rect.y1(), end, rect.y2()),
PhaseAxis::Vertical => Rect::new_saturating(rect.x1(), start, rect.x2(), end),
}
}
fn with_orth_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect {
with_main_interval(rect, axis.other(), start, end)
}
impl PhaseAxis {
fn other(self) -> Self {
match self {
Self::Horizontal => Self::Vertical,
Self::Vertical => Self::Horizontal,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(raw: u32) -> NodeId {
NodeId(raw)
}
fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect {
Rect::new_saturating(x1, y1, x2, y2)
}
fn window(raw: u32, from: Rect, to: Rect) -> MultiphaseWindow {
MultiphaseWindow::new(id(raw), from, to)
}
#[derive(Clone)]
enum TestTree {
Leaf(u32),
Split {
id: u32,
axis: PhaseAxis,
weights: Vec<i32>,
children: Vec<TestTree>,
},
}
struct TestLeaf {
node_id: NodeId,
rect: Rect,
hierarchy: MultiphaseHierarchyPosition,
}
fn leaf(raw: u32) -> TestTree {
TestTree::Leaf(raw)
}
fn split(id: u32, axis: PhaseAxis, weights: &[i32], children: Vec<TestTree>) -> TestTree {
TestTree::Split {
id,
axis,
weights: weights.to_vec(),
children,
}
}
fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec<TestLeaf> {
let mut leaves = vec![];
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
}
#[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(
tree: &TestTree,
bounds: Rect,
hierarchy: TestHierarchy,
leaves: &mut Vec<TestLeaf>,
) {
match tree {
TestTree::Leaf(raw) => leaves.push(TestLeaf {
node_id: id(*raw),
rect: bounds,
hierarchy: MultiphaseHierarchyPosition {
parent: hierarchy.parent,
depth: hierarchy.depth,
sibling_index: hierarchy.sibling_index,
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()
},
}),
TestTree::Split {
id: split_id,
axis,
weights,
children,
} => {
assert_eq!(weights.len(), children.len());
let rects = split_rect_by_weights(bounds, *axis, weights);
for (idx, (child, rect)) in children.iter().zip(rects).enumerate() {
let depth = hierarchy.depth.saturating_add(1);
let mut child_hierarchy = TestHierarchy {
parent: Some(id(*split_id)),
depth,
sibling_index: Some(idx.min(u16::MAX as usize) as u16),
split_axis: Some(*axis),
nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth,
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);
}
}
}
}
fn split_rect_by_weights(bounds: Rect, axis: PhaseAxis, weights: &[i32]) -> Vec<Rect> {
let total_weight: i32 = weights.iter().sum();
assert!(total_weight > 0);
let total_size = match axis {
PhaseAxis::Horizontal => bounds.width(),
PhaseAxis::Vertical => bounds.height(),
};
let mut pos = match axis {
PhaseAxis::Horizontal => bounds.x1(),
PhaseAxis::Vertical => bounds.y1(),
};
let mut remaining_size = total_size;
let mut remaining_weight = total_weight;
let mut rects = vec![];
for (idx, weight) in weights.iter().enumerate() {
let size = if idx + 1 == weights.len() {
remaining_size
} else {
total_size * *weight / total_weight
};
let rect = match axis {
PhaseAxis::Horizontal => {
Rect::new_sized_saturating(pos, bounds.y1(), size, bounds.height())
}
PhaseAxis::Vertical => {
Rect::new_sized_saturating(bounds.x1(), pos, bounds.width(), size)
}
};
rects.push(rect);
pos += size;
remaining_size -= size;
remaining_weight -= *weight;
if remaining_weight == 0 {
assert_eq!(remaining_size, 0);
}
}
rects
}
fn generated_request(old: &TestTree, new: &TestTree, bounds: Rect) -> MultiphaseRequest {
let old_leaves = layout_tree(old, bounds);
let new_leaves = layout_tree(new, bounds);
assert_eq!(old_leaves.len(), new_leaves.len());
let mut windows = vec![];
for old_leaf in &old_leaves {
let new_leaf = new_leaves
.iter()
.find(|leaf| leaf.node_id == old_leaf.node_id)
.unwrap();
windows.push(MultiphaseWindow::with_hierarchy(
old_leaf.node_id,
old_leaf.rect,
new_leaf.rect,
MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy),
));
}
MultiphaseRequest {
bounds,
windows,
clearance: 0,
}
}
fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) {
assert_generated_case_plans_deterministically(old, new, bounds);
}
fn assert_generated_case_plans_deterministically(
old: &TestTree,
new: &TestTree,
bounds: Rect,
) -> MultiphasePlanned {
let req = generated_request(old, new, bounds);
assert!(!overlaps(req.windows.iter().map(|window| window.from)));
assert!(!overlaps(req.windows.iter().map(|window| window.to)));
let first = plan_no_overlap_explained(&req).unwrap();
let second = plan_no_overlap_explained(&req).unwrap();
assert_eq!(first, second);
assert_eq!(first.plan.phases.len(), first.explanation.phases.len());
assert_eq!(
first.explanation.validation,
ValidationExplanation::passed()
);
for phase in &first.explanation.phases {
assert!(phase.nodes.windows(2).all(|pair| pair[0].0 < pair[1].0));
}
assert!(validate_plan_continuous(&req, &first.plan));
first
}
fn bounds_for_axis(axis: PhaseAxis) -> Rect {
match axis {
PhaseAxis::Horizontal => rect(0, 0, 400, 100),
PhaseAxis::Vertical => rect(0, 0, 100, 400),
}
}
fn push_generated_case_bidirectional(
cases: &mut Vec<(TestTree, TestTree, Rect)>,
old: TestTree,
new: TestTree,
bounds: Rect,
) {
cases.push((old.clone(), new.clone(), bounds));
cases.push((new, old, bounds));
}
fn request(windows: Vec<MultiphaseWindow>) -> MultiphaseRequest {
let bounds = windows
.iter()
.map(|window| window.from.union(window.to))
.reduce(|bounds, rect| bounds.union(rect))
.unwrap_or_else(|| rect(0, 0, 1, 1));
MultiphaseRequest {
bounds,
windows,
clearance: 0,
}
}
fn actions(plan: &MultiphasePlan) -> Vec<PhaseAction> {
plan.phases
.iter()
.map(|phase| phase.action.as_uniform().unwrap())
.collect()
}
fn step_to(plan: &MultiphasePlan, phase: usize, node_id: NodeId) -> Rect {
plan.phases[phase]
.steps
.iter()
.find(|step| step.node_id == node_id)
.unwrap()
.to
}
fn no_pattern_attempts(direction: PlanDirection) -> Vec<RejectedStrategy> {
vec![
RejectedStrategy {
direction,
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,
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::OrientationChange {
from_axis: PhaseAxis::Horizontal,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::OrientationChange {
from_axis: PhaseAxis::Vertical,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
},
reason: MultiphasePlanFailure::NoPattern,
},
RejectedStrategy {
direction,
strategy: PlanStrategy::SwapLanes {
axis: PhaseAxis::Vertical,
},
reason: MultiphasePlanFailure::NoPattern,
},
]
}
#[test]
fn horizontal_swap_shrinks_moves_then_grows_without_overlap() {
let req = request(vec![
window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)),
MultiphaseWindow {
node_id: id(2),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
]);
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
]
);
assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50));
assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100));
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal
}
);
assert_eq!(
planned
.explanation
.phases
.iter()
.map(|phase| phase.reason)
.collect::<Vec<_>>(),
vec![
PhaseReason::ShrinkIntoLanes {
lane_axis: PhaseAxis::Vertical
},
PhaseReason::MoveThroughFreedSpace,
PhaseReason::GrowOutOfLanes,
]
);
assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn horizontal_swap_reverse_uses_equivalent_lanes() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 0, 100, 100),
to: rect(100, 0, 200, 100),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50));
assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100));
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn horizontal_swap_lanes_follow_motion_direction_not_node_id() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 0, 100, 100),
to: rect(100, 0, 200, 100),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50));
assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100));
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 one_pixel_uneven_swap_lanes_fold_remainder_into_crossing_phase() {
let req = request(vec![
window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)),
window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert_eq!(step_to(plan, 1, id(1)), rect(101, 0, 201, 50));
assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 101, 100));
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn large_uneven_swap_lanes_split_move_and_same_axis_scale() {
let req = request(vec![
window(1, rect(0, 0, 120, 100), rect(120, 0, 200, 100)),
window(2, rect(120, 0, 200, 100), rect(0, 0, 120, 100)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert_eq!(step_to(plan, 1, id(1)), rect(80, 0, 200, 50));
assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 80, 100));
assert_eq!(step_to(plan, 2, id(1)), rect(120, 0, 200, 50));
assert_eq!(step_to(plan, 2, id(2)), rect(0, 50, 120, 100));
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn uneven_swap_lanes_tolerate_stationary_sibling_in_odd_layout() {
let req = request(vec![
window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)),
window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)),
window(3, rect(201, 0, 300, 100), rect(201, 0, 300, 100)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(&planned.plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn horizontal_rotation_uses_crossing_lanes() {
let req = request(vec![
window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)),
window(2, rect(100, 0, 200, 100), rect(200, 0, 300, 100)),
window(3, rect(200, 0, 300, 100), rect(0, 0, 100, 100)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(&planned.plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn vertical_swap_lanes_follow_motion_direction_not_node_id() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 100, 100, 200),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 0, 100, 100),
to: rect(0, 100, 100, 200),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 50, 100));
assert_eq!(step_to(&plan, 0, id(1)), rect(50, 100, 100, 200));
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn generated_sibling_swaps_plan_for_both_axes() {
let bounds = rect(0, 0, 240, 240);
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
let old = split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]);
let new = split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]);
assert_generated_case_plans(&old, &new, bounds);
}
}
#[test]
fn generated_size_redistributions_plan_as_single_axis_scale() {
let horizontal_old = split(
10,
PhaseAxis::Horizontal,
&[1, 1, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let horizontal_new = split(
10,
PhaseAxis::Horizontal,
&[1, 2, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let horizontal_req =
generated_request(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100));
let horizontal_plan = plan_no_overlap(&horizontal_req).unwrap();
assert_eq!(
actions(&horizontal_plan),
vec![PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
}]
);
assert!(validate_plan_continuous(&horizontal_req, &horizontal_plan));
let vertical_old = split(
10,
PhaseAxis::Vertical,
&[1, 1, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let vertical_new = split(
10,
PhaseAxis::Vertical,
&[1, 2, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let vertical_req = generated_request(&vertical_old, &vertical_new, rect(0, 0, 100, 400));
let vertical_plan = plan_no_overlap(&vertical_req).unwrap();
assert_eq!(
actions(&vertical_plan),
vec![PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
}]
);
assert!(validate_plan_continuous(&vertical_req, &vertical_plan));
}
#[test]
fn mixed_single_phase_accepts_distinct_axis_actions_when_proven() {
let req = request(vec![
window(1, rect(0, 0, 100, 100), rect(0, 0, 150, 100)),
window(2, rect(200, 0, 300, 100), rect(200, 0, 300, 150)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase);
assert_eq!(planned.plan.phases.len(), 1);
assert_eq!(
planned.plan.phases[0].action,
MultiphasePhaseAction::Mixed(vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
])
);
assert_eq!(
planned.explanation.phases[0].reason,
PhaseReason::MixedAxisActions
);
assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn mixed_single_phase_accepts_move_and_scale_when_proven() {
let req = request(vec![
window(1, rect(0, 0, 80, 80), rect(40, 0, 120, 80)),
window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase);
assert_eq!(
planned.plan.phases[0].action,
MultiphasePhaseAction::Mixed(vec![
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
])
);
assert_eq!(
planned.explanation.phases[0].reason,
PhaseReason::MixedAxisActions
);
assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn single_window_one_axis_group_is_still_multiphase_plannable() {
let req = request(vec![window(1, rect(0, 0, 100, 100), rect(40, 0, 140, 100))]);
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(planned.explanation.strategy, PlanStrategy::SingleAction);
assert_eq!(
planned.plan.phases[0].action,
MultiphasePhaseAction::Uniform(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
})
);
assert_eq!(planned.explanation.phases[0].nodes, vec![id(1)]);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn mixed_single_phase_still_rejects_diagonal_per_window_motion() {
let req = request(vec![
window(1, rect(0, 0, 80, 80), rect(40, 40, 120, 120)),
window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)),
]);
assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan));
let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err();
let rejection = MultiphasePlanFailure::InvalidPhaseStep {
action: PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
node_id: id(1),
};
assert_eq!(diagnostic.forward, rejection);
assert_eq!(diagnostic.reverse, Some(rejection));
}
#[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 planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
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_eq!(
planned.explanation.strategy,
PlanStrategy::HierarchyOrderedScales
);
assert_eq!(
planned.explanation.phases[0].reason,
PhaseReason::ParentAxisBeforeChildAxis {
parent_axis: PhaseAxis::Horizontal,
parent_depth: 1,
child_axis: PhaseAxis::Vertical,
child_depth: 2,
}
);
assert_eq!(
planned.explanation.phases[0].nodes,
vec![id(1), id(2), id(3)]
);
assert_eq!(planned.explanation.phases[1].nodes, vec![id(2), id(3)]);
assert_eq!(
planned.explanation.validation,
ValidationExplanation::passed()
);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn orientation_change_shrinks_moves_then_grows() {
let req = request(vec![
window(1, rect(0, 0, 200, 400), rect(0, 0, 400, 200)),
window(2, rect(200, 0, 400, 400), rect(0, 200, 400, 400)),
]);
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
planned.explanation.strategy,
PlanStrategy::OrientationChange {
from_axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
]
);
assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 200, 200));
assert_eq!(step_to(plan, 0, id(2)), rect(200, 200, 400, 400));
assert_eq!(step_to(plan, 1, id(2)), rect(0, 200, 200, 400));
assert_eq!(step_to(plan, 2, id(1)), rect(0, 0, 400, 200));
assert_eq!(step_to(plan, 2, id(2)), rect(0, 200, 400, 400));
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]
fn generated_stack_extractions_plan_for_both_axes_and_directions() {
let horizontal_old = split(
10,
PhaseAxis::Horizontal,
&[1, 1],
vec![
leaf(1),
split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]),
],
);
let horizontal_new = split(
10,
PhaseAxis::Horizontal,
&[1, 2, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
assert_generated_case_plans(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100));
assert_generated_case_plans(&horizontal_new, &horizontal_old, rect(0, 0, 400, 100));
let vertical_old = split(
20,
PhaseAxis::Vertical,
&[1, 1],
vec![
leaf(1),
split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]),
],
);
let vertical_new = split(
20,
PhaseAxis::Vertical,
&[1, 2, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
assert_generated_case_plans(&vertical_old, &vertical_new, rect(0, 0, 100, 400));
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 three_child_stack_extraction_plans_without_linear_fallback() {
let old = split(
10,
PhaseAxis::Horizontal,
&[1, 1],
vec![
leaf(1),
split(
11,
PhaseAxis::Vertical,
&[1, 1, 1],
vec![leaf(2), leaf(3), leaf(4)],
),
],
);
let new = split(
10,
PhaseAxis::Horizontal,
&[1, 1, 1],
vec![
leaf(1),
leaf(3),
split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(4)]),
],
);
let req = generated_request(&old, &new, rect(0, 0, 600, 300));
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal,
}
);
assert_eq!(
actions(&planned.plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
},
]
);
assert!(validate_plan_continuous(&req, &planned.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![];
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
let child_axis = axis.other();
let bounds = bounds_for_axis(axis);
push_generated_case_bidirectional(
&mut cases,
split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]),
split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]),
bounds,
);
push_generated_case_bidirectional(
&mut cases,
split(10, axis, &[1, 1, 1], vec![leaf(1), leaf(2), leaf(3)]),
split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]),
bounds,
);
push_generated_case_bidirectional(
&mut cases,
split(
10,
axis,
&[1, 1],
vec![
leaf(1),
split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]),
],
),
split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]),
bounds,
);
push_generated_case_bidirectional(
&mut cases,
split(
10,
axis,
&[1, 1],
vec![
split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]),
leaf(3),
],
),
split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]),
bounds,
);
push_generated_case_bidirectional(
&mut cases,
split(
10,
axis,
&[1, 1],
vec![
leaf(1),
split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]),
],
),
split(
10,
axis,
&[1, 3],
vec![
leaf(1),
split(11, child_axis, &[3, 1], vec![leaf(2), leaf(3)]),
],
),
bounds,
);
push_generated_case_bidirectional(
&mut cases,
split(
10,
axis,
&[1, 1],
vec![
split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]),
leaf(3),
],
),
split(
10,
axis,
&[3, 1],
vec![
split(11, child_axis, &[1, 3], vec![leaf(1), leaf(2)]),
leaf(3),
],
),
bounds,
);
}
assert_eq!(cases.len(), 24);
for (old, new, bounds) in cases {
assert_generated_case_plans_deterministically(&old, &new, bounds);
}
}
#[test]
fn stack_extraction_creates_space_before_moving_child() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 200, 100),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(200, 0, 400, 50),
to: rect(100, 0, 300, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(200, 50, 400, 100),
to: rect(300, 0, 400, 100),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
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!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100));
assert_eq!(plan.phases[0].steps[1].to, rect(300, 50, 400, 100));
assert_eq!(plan.phases[1].steps[0].to, rect(100, 0, 300, 50));
assert_eq!(plan.phases[2].steps[0].to, rect(100, 0, 300, 100));
assert_eq!(plan.phases[2].steps[1].to, rect(300, 0, 400, 100));
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn stack_extraction_reverse_replays_phases_in_reverse() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(0, 0, 200, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(100, 0, 300, 100),
to: rect(200, 0, 400, 50),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(300, 0, 400, 100),
to: rect(200, 50, 400, 100),
hierarchy: Default::default(),
},
]);
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal
},
]
);
assert_eq!(
planned.explanation.strategy,
PlanStrategy::ReversedForwardPlan {
original: Box::new(PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal
})
}
);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
fn vertical_stack_extraction_creates_space_before_moving_child() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 200),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 200, 50, 400),
to: rect(0, 100, 100, 300),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(50, 200, 100, 400),
to: rect(0, 300, 100, 400),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal
},
]
);
assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100));
assert_eq!(plan.phases[0].steps[1].to, rect(50, 300, 100, 400));
assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300));
assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300));
assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400));
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn vertical_stack_extraction_with_clearance_still_plans() {
let old = split(
20,
PhaseAxis::Vertical,
&[1, 1],
vec![
leaf(1),
split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]),
],
);
let new = split(
20,
PhaseAxis::Vertical,
&[1, 2, 1],
vec![leaf(1), leaf(2), leaf(3)],
);
let mut req = generated_request(&old, &new, rect(0, 0, 100, 400));
req.clearance = 10;
let planned = plan_no_overlap_explained(&req).unwrap();
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Vertical,
}
);
assert!(validate_plan_continuous(&req, &planned.plan));
}
#[test]
fn vertical_stack_extraction_reverse_replays_phases_in_reverse() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(0, 0, 100, 200),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 100, 100, 300),
to: rect(0, 200, 50, 400),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(0, 300, 100, 400),
to: rect(50, 200, 100, 400),
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
]
);
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn unsupported_diagonal_motion_falls_back_to_linear() {
let req = request(vec![MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(100, 100, 200, 200),
hierarchy: Default::default(),
}]);
assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan));
let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err();
assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern);
assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern));
let mut expected = no_pattern_attempts(PlanDirection::Forward);
expected.extend(no_pattern_attempts(PlanDirection::Reverse));
assert_eq!(diagnostic.attempted, expected);
}
#[test]
fn diagnostics_report_shrink_bound_rejections() {
let req = MultiphaseRequest {
bounds: rect(0, 0, 400, 100),
clearance: 0,
windows: vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 200, 100),
to: rect(0, 0, 10, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(200, 0, 400, 100),
to: rect(10, 0, 400, 100),
hierarchy: Default::default(),
},
],
};
assert!(matches!(
plan_no_overlap_with_diagnostics(&req).unwrap_err().forward,
MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Horizontal,
available: 10,
required: 50,
}
));
}
#[test]
fn diagnostics_report_candidate_validation_rejections() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 60, 60),
to: rect(180, 0, 240, 60),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(90, 0, 150, 60),
to: rect(90, 0, 150, 60),
hierarchy: Default::default(),
},
]);
let rejection =
MultiphasePlanFailure::Validation(MultiphaseValidationError::PhaseOverlap {
phase: 0,
a: id(1),
b: id(2),
});
let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err();
assert_eq!(diagnostic.forward, rejection);
assert_eq!(diagnostic.reverse, Some(rejection));
assert_eq!(
diagnostic.attempted[0],
RejectedStrategy {
direction: PlanDirection::Forward,
strategy: PlanStrategy::SingleAction,
reason: rejection,
}
);
assert!(diagnostic.attempted.iter().any(|attempt| *attempt
== RejectedStrategy {
direction: PlanDirection::Reverse,
strategy: PlanStrategy::SingleAction,
reason: rejection,
}));
}
#[test]
fn hierarchy_metadata_classifies_depth_and_mono_transitions() {
let source = MultiphaseHierarchyPosition {
parent: Some(id(10)),
depth: 2,
sibling_index: Some(0),
split_axis: Some(PhaseAxis::Vertical),
nearest_horizontal_split_depth: Some(1),
nearest_vertical_split_depth: Some(2),
..Default::default()
};
let target = MultiphaseHierarchyPosition {
parent: Some(id(11)),
depth: 1,
sibling_index: Some(2),
split_axis: Some(PhaseAxis::Horizontal),
nearest_horizontal_split_depth: Some(1),
..Default::default()
};
assert_eq!(
MultiphaseWindowHierarchy::new(source, target).transition,
MultiphaseHierarchyTransition::Ascending
);
assert_eq!(source.nearest_vertical_split_depth, Some(2));
let entering_mono = MultiphaseWindowHierarchy::new(
source,
MultiphaseHierarchyPosition {
parent_is_mono: true,
mono_active: true,
..target
},
);
assert_eq!(
entering_mono.transition,
MultiphaseHierarchyTransition::EnteringMono
);
assert_eq!(
entering_mono.reversed().transition,
MultiphaseHierarchyTransition::ExitingMono
);
}
#[test]
fn continuous_validation_rejects_narrow_mid_phase_overlap() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 10, 10),
to: rect(100, 0, 110, 10),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(13, 0, 14, 10),
to: rect(13, 0, 14, 10),
hierarchy: Default::default(),
},
]);
let plan = MultiphasePlan {
phases: vec![MultiphasePhase {
action: MultiphasePhaseAction::Uniform(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}),
steps: vec![MultiphaseStep {
node_id: id(1),
from: rect(0, 0, 10, 10),
to: rect(100, 0, 110, 10),
}],
}],
};
assert_eq!(
validate_plan_continuous_diagnostic(&req, &plan),
Err(MultiphaseValidationError::PhaseOverlap {
phase: 0,
a: id(1),
b: id(2),
})
);
}
#[test]
fn continuous_validation_allows_edge_touching_motion() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 10, 10),
to: rect(10, 0, 20, 10),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(20, 0, 30, 10),
to: rect(20, 0, 30, 10),
hierarchy: Default::default(),
},
]);
let plan = MultiphasePlan {
phases: vec![MultiphasePhase {
action: MultiphasePhaseAction::Uniform(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}),
steps: vec![MultiphaseStep {
node_id: id(1),
from: rect(0, 0, 10, 10),
to: rect(10, 0, 20, 10),
}],
}],
};
assert!(validate_plan_continuous(&req, &plan));
}
#[test]
fn continuous_validation_rejects_mixed_phase_action_count_mismatch() {
let req = request(vec![
window(1, rect(0, 0, 40, 40), rect(40, 0, 80, 40)),
window(2, rect(100, 0, 140, 40), rect(100, 0, 140, 80)),
]);
let plan = MultiphasePlan {
phases: vec![MultiphasePhase {
action: MultiphasePhaseAction::Mixed(vec![PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}]),
steps: vec![
MultiphaseStep {
node_id: id(1),
from: rect(0, 0, 40, 40),
to: rect(40, 0, 80, 40),
},
MultiphaseStep {
node_id: id(2),
from: rect(100, 0, 140, 40),
to: rect(100, 0, 140, 80),
},
],
}],
};
assert_eq!(
validate_plan_continuous_diagnostic(&req, &plan),
Err(MultiphaseValidationError::PhaseActionCount {
phase: 0,
actions: 1,
steps: 2,
})
);
}
#[test]
fn continuous_validation_rejects_stale_step_start_rect() {
let req = request(vec![MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 10, 10),
to: rect(20, 0, 30, 10),
hierarchy: Default::default(),
}]);
let plan = MultiphasePlan {
phases: vec![MultiphasePhase {
action: MultiphasePhaseAction::Uniform(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}),
steps: vec![MultiphaseStep {
node_id: id(1),
from: rect(5, 0, 15, 10),
to: rect(20, 0, 30, 10),
}],
}],
};
assert_eq!(
validate_plan_continuous_diagnostic(&req, &plan),
Err(MultiphaseValidationError::StaleStepStart {
phase: 0,
node_id: id(1),
})
);
}
#[test]
fn motion_groups_split_disjoint_layout_changes() {
let windows = vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(100, 0, 200, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(300, 0, 400, 100),
to: rect(400, 0, 500, 100),
hierarchy: Default::default(),
},
];
assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1], vec![2]]);
}
#[test]
fn motion_groups_are_transitive() {
let windows = vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(80, 0, 180, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(170, 0, 270, 100),
to: rect(250, 0, 350, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(3),
from: rect(90, 0, 180, 100),
to: rect(180, 0, 260, 100),
hierarchy: Default::default(),
},
];
assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1, 2]]);
}
#[test]
fn motion_groups_join_across_animation_clearance() {
let windows = vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(0, 0, 80, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(120, 0, 220, 100),
to: rect(110, 0, 210, 100),
hierarchy: Default::default(),
},
];
assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0], vec![1]]);
assert_eq!(partition_motion_groups(&windows, 10), vec![vec![0, 1]]);
}
}