From 41d2fef1775568dd106047859cbb0046f5dc3d22 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:23:33 +1000 Subject: [PATCH] Add multiphase no-overlap planner groundwork --- docs/window-animations-plan.md | 19 ++ src/animation.rs | 2 + src/animation/multiphase.rs | 608 +++++++++++++++++++++++++++++++++ 3 files changed, 629 insertions(+) create mode 100644 src/animation/multiphase.rs diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 6b2261ff..d46b3bdb 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -30,6 +30,15 @@ be handled deliberately. partial inspiration for tiling style and titlebar/grouping behavior. - Mono mode should mostly avoid animations. Exceptions are windows entering or exiting mono mode, where a visual transition can clarify the hierarchy change. +- Multiphase shrink steps should not normally need to reduce a tiled window far + below roughly one quarter of the relevant full size. The implementation may + enforce a conservative sanity minimum, and pathological cases may fall back. +- If the no-overlap planner cannot produce a legal sequence, only the affected + group should fall back to linear animation. This is expected to be rare for + valid tiling layouts. +- When entering mono mode, the active child should animate to the mono geometry. + Inactive siblings may snap invisible. Floats may overlap normally and do not + need the no-overlap planner. ## Texture Freezing Decision @@ -188,12 +197,22 @@ Preferred approach: animated visual actors. - Derive every leaf's per-phase rect from one phase schedule so parent and child effects cannot compose into forbidden motion. +- Build the planner as pure geometry first. Live integration should collect + eligible leaf `(old, new)` rects across a command-driven layout pass, then + submit planner-produced phases as a batch. Per-node `tl_change_extents` calls + are too incremental to plan safely by themselves. - Add container-level grouping only after the leaf planner proves correct. - Include hierarchy-transition metadata in the planner input: source parent, target parent, source depth, target depth, and whether the window is ascending, descending, or staying at the same hierarchy level. - For mono containers, suppress ordinary in-mono focus/tab changes. Animate only transitions into mono, out of mono, or across the mono boundary. +- When entering mono, the active child animates to the full mono area and + inactive siblings snap invisible. When exiting mono, ordinary tiled geometry + may animate from the mono child where that produces a clear hierarchy + transition. +- If a legal no-overlap sequence cannot be found for a group, fall back to the + linear animator for that group only. Float windows are outside this invariant. Tests: diff --git a/src/animation.rs b/src/animation.rs index 2f62d5ab..847b8f6d 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -16,6 +16,8 @@ use { }, }; +pub mod multiphase; + const DEFAULT_DURATION_MS: u32 = 160; const CURVE_MAX_POINTS: usize = 33; const CURVE_FLATNESS_EPSILON: f32 = 0.001; diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs new file mode 100644 index 00000000..3130bb80 --- /dev/null +++ b/src/animation/multiphase.rs @@ -0,0 +1,608 @@ +use {crate::rect::Rect, crate::tree::NodeId}; + +const MIN_SHRINK_DENOMINATOR: i32 = 4; +const PHASE_VALIDATION_SAMPLES: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; + +#[derive(Clone, Debug)] +pub struct MultiphaseRequest { + pub bounds: Rect, + pub windows: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphaseWindow { + pub node_id: NodeId, + pub from: Rect, + pub to: Rect, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlan { + pub phases: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePhase { + pub action: PhaseAction, + pub steps: Vec, +} + +#[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, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphaseError { + EmptyBounds, + EmptyWindow, + DuplicateWindow, + InitialOverlap, + FinalOverlap, + NoPlan, +} + +pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { + validate_request(request)?; + if request + .windows + .iter() + .all(|window| window.from == window.to) + { + return Ok(MultiphasePlan { phases: vec![] }); + } + if let Some(plan) = plan_forward(request) { + return Ok(plan); + } + let reversed = reverse_request(request); + if let Some(plan) = plan_forward(&reversed) { + return Ok(reverse_plan(plan)); + } + Err(MultiphaseError::NoPlan) +} + +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 plan_forward(request: &MultiphaseRequest) -> Option { + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + if let Some(plan) = plan_axis_crossing_lanes(request, axis) { + return Some(plan); + } + } + plan_horizontal_space_then_vertical_growth(request) +} + +fn plan_axis_crossing_lanes( + request: &MultiphaseRequest, + axis: PhaseAxis, +) -> Option { + if request.windows.len() != 2 { + return None; + } + let orth_min = request + .windows + .iter() + .map(|window| orth_start(window.from, axis)) + .min()?; + let orth_max = request + .windows + .iter() + .map(|window| orth_end(window.from, axis)) + .max()?; + if request.windows.iter().any(|window| { + main_size(window.from, axis) != main_size(window.to, axis) + || orth_start(window.from, axis) != orth_min + || orth_end(window.from, axis) != orth_max + || 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 None; + } + let lane_size = (orth_max - orth_min) / request.windows.len() as i32; + if lane_size < sane_min_size(orth_max - orth_min) { + return None; + } + + let mut windows = request.windows.clone(); + windows.sort_by_key(|window| window.node_id.0); + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for (idx, window) in windows.iter().enumerate() { + let lane_start = orth_min + lane_size * idx as i32; + let lane_end = if idx + 1 == windows.len() { + orth_max + } else { + lane_start + lane_size + }; + 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), + ); + push_step(&mut phase1, window.node_id, window.from, lane_from); + push_step(&mut phase2, window.node_id, lane_from, lane_to); + push_step(&mut phase3, window.node_id, lane_to, window.to); + } + build_validated_plan( + request, + [ + (PhaseKind::Scale, axis.other(), phase1), + (PhaseKind::Move, axis, phase2), + (PhaseKind::Scale, axis.other(), phase3), + ], + ) +} + +fn plan_horizontal_space_then_vertical_growth( + request: &MultiphaseRequest, +) -> Option { + if request.windows.len() < 2 { + return None; + } + 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 || window.to.height() < min_height { + return None; + } + let x_changes = window.from.x1() != window.to.x1() || window.from.x2() != window.to.x2(); + let y_changes = window.from.y1() != window.to.y1() || window.from.y2() != window.to.y2(); + if x_changes && window.from.width() == window.to.width() { + let after_move = Rect::new_sized_saturating( + window.to.x1(), + window.from.y1(), + window.to.width(), + window.from.height(), + ); + push_step(&mut phase2, window.node_id, window.from, after_move); + if y_changes { + push_step(&mut phase3, window.node_id, after_move, window.to); + } + } else if x_changes { + let after_x_scale = Rect::new_sized_saturating( + window.to.x1(), + window.from.y1(), + window.to.width(), + window.from.height(), + ); + push_step(&mut phase1, window.node_id, window.from, after_x_scale); + if y_changes { + push_step(&mut phase3, window.node_id, after_x_scale, window.to); + } + } else if y_changes { + push_step(&mut phase3, window.node_id, window.from, window.to); + } + } + if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() { + return None; + } + build_validated_plan( + request, + [ + (PhaseKind::Scale, PhaseAxis::Horizontal, phase1), + (PhaseKind::Move, PhaseAxis::Horizontal, phase2), + (PhaseKind::Scale, PhaseAxis::Vertical, phase3), + ], + ) +} + +fn build_validated_plan( + request: &MultiphaseRequest, + phases: [(PhaseKind, PhaseAxis, Vec); N], +) -> Option { + let phases: Vec<_> = phases + .into_iter() + .filter_map(|(kind, axis, steps)| { + (!steps.is_empty()).then_some(MultiphasePhase { + action: PhaseAction { kind, axis }, + steps, + }) + }) + .collect(); + if phases.iter().any(|phase| { + phase + .steps + .iter() + .any(|step| classify_step(*step) != Some(phase.action)) + }) { + return None; + } + let plan = MultiphasePlan { phases }; + validate_plan_samples(request, &plan).then_some(plan) +} + +fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + if overlaps(current.iter().map(|(_, rect)| *rect)) { + return false; + } + for phase in &plan.phases { + for t in PHASE_VALIDATION_SAMPLES { + let rects = current.iter().map(|(node_id, rect)| { + phase + .steps + .iter() + .find(|step| step.node_id == *node_id) + .map(|step| super::lerp_rect(step.from, step.to, t)) + .unwrap_or(*rect) + }); + if overlaps(rects) { + return false; + } + } + for step in &phase.steps { + let Some((_, rect)) = current + .iter_mut() + .find(|(node_id, _)| *node_id == step.node_id) + else { + return false; + }; + *rect = step.to; + } + if overlaps(current.iter().map(|(_, rect)| *rect)) { + return false; + } + } + request.windows.iter().all(|window| { + current + .iter() + .find(|(node_id, _)| *node_id == window.node_id) + .is_some_and(|(_, rect)| *rect == window.to) + }) +} + +fn classify_step(step: MultiphaseStep) -> Option { + 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 reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { + MultiphaseRequest { + bounds: request.bounds, + windows: request + .windows + .iter() + .map(|window| MultiphaseWindow { + node_id: window.node_id, + from: window.to, + to: window.from, + }) + .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 overlaps(rects: impl IntoIterator) -> 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 push_step(steps: &mut Vec, 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 request(windows: Vec) -> MultiphaseRequest { + MultiphaseRequest { + bounds: rect(0, 0, 400, 100), + windows, + } + } + + fn actions(plan: &MultiphasePlan) -> Vec { + plan.phases.iter().map(|phase| phase.action).collect() + } + + #[test] + fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + ]); + 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::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!(validate_plan_samples(&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), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(plan.phases[0].steps[0].to, rect(100, 0, 200, 50)); + assert_eq!(plan.phases[0].steps[1].to, rect(0, 50, 100, 100)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[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), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(200, 0, 400, 50), + to: rect(100, 0, 300, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(200, 50, 400, 100), + to: rect(300, 0, 400, 100), + }, + ]); + 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_samples(&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), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 300, 100), + to: rect(200, 0, 400, 50), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(200, 50, 400, 100), + }, + ]); + 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::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert!(validate_plan_samples(&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), + }]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + } +}