diff --git a/compositor/src/state/presentation_dependencies.rs b/compositor/src/state/presentation_dependencies.rs index 1efcbc5..dc80aff 100644 --- a/compositor/src/state/presentation_dependencies.rs +++ b/compositor/src/state/presentation_dependencies.rs @@ -30,6 +30,106 @@ pub(super) struct ViewportDependencies { const MAX_VIEWPORT_STEPS: usize = 32; +/// Connect moving tiles where their conservative swept bounds overlap. Such +/// tiles must hand off together: advancing one into a neighbor's +/// still-presented source would break the resize/reflow boundary. A fixed +/// boundary between independent branches merely touches and creates no edge. +/// Use solved presentation rectangles, so constraints, gaps and interrupted +/// motion participate rather than assuming structural siblings are independent. +fn tree_reflow_groups( + motions: impl IntoIterator, + inner_gap: f64, + allow_overshoot: bool, +) -> HashMap { + let motions = motions + .into_iter() + .filter(|(_, source, target, velocity)| { + source != target + || [velocity.x, velocity.y, velocity.width, velocity.height] + .into_iter() + .any(|speed| speed != 0.0) + }) + .collect::>(); + // Endpoint bounds cannot contain an overshooting spring or a retargeted + // component still moving away from its destination. Keep the moving tiles + // together until their trajectories can be bounded by their endpoints. + let reversing = motions.iter().any(|(_, source, target, velocity)| { + [ + (target.x - source.x, velocity.x), + (target.y - source.y, velocity.y), + (target.width - source.width, velocity.width), + (target.height - source.height, velocity.height), + ] + .into_iter() + .any(|(distance, speed)| speed != 0.0 && distance * speed <= 0.0) + }); + if allow_overshoot || reversing { + return motions.into_iter().map(|(id, ..)| (id, 0)).collect(); + } + let margin = inner_gap.max(0.0) / 2.0; + let moving = motions + .into_iter() + .filter(|(_, source, target, _)| source != target) + .map(|(id, source, target, velocity)| { + let left = source.x.min(target.x) - margin; + let top = source.y.min(target.y) - margin; + // Geometry uses one no-crossing spring for its components. With + // proportional velocities an edge shares their progress, including + // a fixed split boundary. Interrupted components can have different + // progress: bound each separately rather than miss an intermediate + // excursion of x+width or y+height beyond both endpoint rectangles. + let edge = + |position: f64, size: f64, target_position: f64, target_size: f64, speed: f64, size_speed: f64| { + if (speed * (target_size - size) - size_speed * (target_position - position)).abs() < 1e-6 { + (position + size).max(target_position + target_size) + } else { + position.max(target_position) + size.max(target_size) + } + }; + let right = edge( + source.x, + source.width, + target.x, + target.width, + velocity.x, + velocity.width, + ) + margin; + let bottom = edge( + source.y, + source.height, + target.y, + target.height, + velocity.y, + velocity.height, + ) + margin; + (id, Rect::new(left, top, right - left, bottom - top)) + }) + .collect::>(); + let mut groups = HashMap::new(); + for (id, _) in &moving { + if groups.contains_key(id) { + continue; + } + let group = groups.len(); + groups.insert(*id, group); + let mut frontier = vec![*id]; + while let Some(id) = frontier.pop() { + let bounds = moving.iter().find(|(window, _)| *window == id).unwrap().1; + for (other, candidate) in &moving { + if !groups.contains_key(other) + && bounds.x.max(candidate.x) + 0.001 < (bounds.x + bounds.width).min(candidate.x + candidate.width) + && bounds.y.max(candidate.y) + 0.001 + < (bounds.y + bounds.height).min(candidate.y + candidate.height) + { + groups.insert(*other, group); + frontier.push(*other); + } + } + } + } + groups +} + /// The buffer-dependent properties in this path. Translation has its own /// inputs; it does not inherit a tile's resize/reflow wait by membership. #[derive(Default)] @@ -268,6 +368,37 @@ impl Ferese { let mut reflow = HashMap::new(); let mut viewport = HashMap::new(); let mut previous_viewport = std::mem::take(&mut self.presentation_dependencies.viewport); + let tree_groups = self + .workspaces + .iter() + .filter_map(|workspace| { + let WorkspaceLayout::Tree(tree) = &workspace.layout else { + return None; + }; + let motions = tree.window_ids().filter_map(|id| { + let record = self.windows.record(id)?; + // Output-owned geometry does not participate in tiled reflow. + if workspace.fullscreen == Some(id) || record.maximized { + return None; + } + let geometry = record.geometry?; + Some(( + id, + geometry.visual.current, + geometry.visual.target, + geometry.visual.velocity, + )) + }); + Some(( + workspace.id, + tree_reflow_groups( + motions, + self.gap_config.inner, + self.spring_config.crossing == ferese_animation::CrossingPolicy::AllowOvershoot, + ), + )) + }) + .collect::>(); for (&id, _) in self.windows.records() { let Some(workspace_id) = self.workspaces.workspace_for_window(id) else { continue; @@ -306,9 +437,9 @@ impl Ferese { source == own || (source < own && transaction.width_changes()) }) }), - WorkspaceLayout::Tree(_) => { - workspace.layout.contains(id) && workspace.layout.contains(*slow) - } + WorkspaceLayout::Tree(_) => tree_groups.get(&workspace_id).is_some_and(|groups| { + groups.get(&id).is_some_and(|group| groups.get(slow) == Some(group)) + }), }); if linked { waits.push(commit); @@ -366,6 +497,193 @@ mod tests { use super::*; use crate::resize_transaction::ResizeTransaction; + fn tree_reflow_groups( + motions: impl IntoIterator, + inner_gap: f64, + ) -> HashMap { + super::tree_reflow_groups( + motions + .into_iter() + .map(|(id, source, target)| (id, source, target, Default::default())), + inner_gap, + false, + ) + } + + #[test] + fn interrupted_components_use_conservative_edge_bounds() { + let source = Rect::new(0.0, 0.0, 100.0, 100.0); + let target = Rect::new(40.0, 0.0, 60.0, 100.0); + let neighbor = Rect::new(110.0, 0.0, 100.0, 100.0); + let moving_neighbor = Rect::new(110.0, 0.0, 110.0, 100.0); + let groups = super::tree_reflow_groups( + [ + ( + WindowId(1), + source, + target, + ferese_animation::RectVelocity { + x: 100.0, + ..Default::default() + }, + ), + (WindowId(2), neighbor, moving_neighbor, Default::default()), + ], + 0.0, + false, + ); + assert_eq!(groups[&WindowId(1)], groups[&WindowId(2)]); + let groups = tree_reflow_groups( + [(WindowId(1), source, target), (WindowId(2), neighbor, moving_neighbor)], + 0.0, + ); + assert_ne!( + groups[&WindowId(1)], + groups[&WindowId(2)], + "coherent fixed edge remains independent" + ); + } + + fn nested_tree() -> ferese_layout::LayoutTree { + let mut tree = ferese_layout::LayoutTree::default(); + tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap(); + tree.insert(WindowId(3), Some(WindowId(1)), Axis::Horizontal, 0.5) + .unwrap(); + tree.insert(WindowId(2), Some(WindowId(1)), Axis::Vertical, 0.5) + .unwrap(); + tree.insert(WindowId(4), Some(WindowId(3)), Axis::Vertical, 0.5) + .unwrap(); + tree + } + + #[test] + fn solved_tree_motion_links_shared_boundaries_but_not_independent_branches() { + let bounds = Rect::new(0.0, 0.0, 800.0, 600.0); + for gap in [0.0, 10.0, 80.0] { + let gaps = GapConfig { + inner: gap, + outer: 10.0, + smart: false, + }; + let mut tree = nested_tree(); + let solve = |tree: &ferese_layout::LayoutTree, constraints: &HashMap| { + tree.geometry_with_constraints(bounds, gaps, constraints, Some(WindowId(1))) + .unwrap() + .geometry + }; + let constraints = HashMap::new(); + let source = solve(&tree, &constraints); + tree.resize_window(WindowId(1), Direction::Down, 0.1).unwrap(); + tree.resize_window(WindowId(3), Direction::Down, 0.1).unwrap(); + let target = solve(&tree, &constraints); + let group = |source: &HashMap, target: &HashMap| { + tree_reflow_groups( + source + .iter() + .filter_map(|(&id, &rect)| Some((id, rect, *target.get(&id)?))), + gap, + ) + }; + let groups = group(&source, &target); + assert_eq!(groups[&WindowId(1)], groups[&WindowId(2)]); + assert_eq!(groups[&WindowId(3)], groups[&WindowId(4)]); + assert_ne!(groups[&WindowId(1)], groups[&WindowId(3)]); + + // A horizontal request walks through the vertical child to the + // root split: both branches' actual x/width now participate. + tree.resize_window(WindowId(1), Direction::Right, 0.1).unwrap(); + let ancestor = solve(&tree, &constraints); + let groups = group(&source, &ancestor); + assert!(groups.values().all(|group| *group == groups[&WindowId(1)])); + + // A minimum in a descendant can move an ancestor boundary even + // though no ancestor ratio was explicitly changed. + let tree = nested_tree(); + let constrained = solve( + &tree, + &HashMap::from([( + WindowId(1), + SizeConstraints { + min_width: 500.0, + ..Default::default() + }, + )]), + ); + let groups = group(&source, &constrained); + assert_eq!(groups[&WindowId(1)], groups[&WindowId(3)]); + assert_eq!(groups[&WindowId(2)], groups[&WindowId(4)]); + assert_ne!( + groups[&WindowId(1)], + groups[&WindowId(2)], + "fixed row boundary remains independent" + ); + + // Maximum constraints clip actual presented bounds. A clipped + // leaf that does not move cannot hold the changing allocation. + let maximum = HashMap::from([( + WindowId(1), + SizeConstraints { + max_width: Some(100.0), + ..Default::default() + }, + )]); + let clipped_source = solve(&tree, &maximum); + let mut wider = tree.clone(); + wider.resize_window(WindowId(1), Direction::Right, 0.1).unwrap(); + let clipped_target = solve(&wider, &maximum); + assert_eq!(clipped_source[&WindowId(1)], clipped_target[&WindowId(1)]); + assert!(!group(&clipped_source, &clipped_target).contains_key(&WindowId(1))); + + // Retarget from the held presentation, not the previous logical + // destination. Reversing one branch leaves the other independent. + let mut reversed = target.clone(); + for id in [WindowId(1), WindowId(2)] { + reversed.insert(id, source[&id]); + } + let groups = group(&source, &reversed); + assert!(!groups.contains_key(&WindowId(1))); + assert_eq!(groups[&WindowId(3)], groups[&WindowId(4)]); + } + } + + #[test] + fn tree_motion_ignores_settled_and_hidden_tiles_and_links_transitive_reflow() { + let ids = [WindowId(1), WindowId(2), WindowId(3), WindowId(4)]; + let source = ids.map(|id| Rect::new((id.0 - 1) as f64 * 100.0, 0.0, 100.0, 100.0)); + let target = [ + Rect::new(0.0, 0.0, 140.0, 100.0), + Rect::new(140.0, 0.0, 100.0, 100.0), + Rect::new(240.0, 0.0, 60.0, 100.0), + source[3], + ]; + let groups = tree_reflow_groups( + ids.into_iter().enumerate().map(|(i, id)| (id, source[i], target[i])), + 0.0, + ); + assert_eq!( + groups[&ids[0]], groups[&ids[2]], + "shared boundaries connect transitively" + ); + assert!(!groups.contains_key(&ids[3]), "settled tile owns no reflow"); + + let mut tree = nested_tree(); + tree.stack_window(WindowId(2), WindowId(1)).unwrap(); + let before = tree.geometry(Rect::new(0.0, 0.0, 800.0, 600.0)).unwrap(); + tree.resize_window(WindowId(2), Direction::Right, 0.1).unwrap(); + let after = tree.geometry(Rect::new(0.0, 0.0, 800.0, 600.0)).unwrap(); + let groups = tree_reflow_groups( + before + .iter() + .filter_map(|(&id, &rect)| Some((id, rect, *after.get(&id)?))), + 0.0, + ); + assert!( + !groups.contains_key(&WindowId(1)), + "inactive tab has no presented geometry" + ); + assert_eq!(groups[&WindowId(2)], groups[&WindowId(3)]); + } + #[test] fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() { use ferese_layout::{ColumnWidth, ScrollingLayout}; diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 9366e4f..e5a27f0 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -31,6 +31,123 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese (events, state, output) } +#[test] +fn tree_resize_does_not_hold_motion_in_an_unchanged_neighboring_branch() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::tree_resize_does_not_hold_motion_in_an_unchanged_neighboring_branch", + ) { + return; + } + let (mut events, mut state, output) = fixture(); + state.animation_test_time = Some(Duration::ZERO); + state.gap_config = GapConfig { + inner: 10.0, + outer: 10.0, + smart: false, + }; + let mut clients = (0..4) + .map(|_| window(&mut state, &mut events, 0xff112233)) + .collect::>(); + let ids = clients + .iter() + .map(|(window, _)| state.windows.ids()[window]) + .collect::>(); + let [a, b, c, d] = ids.as_slice() else { unreachable!() }; + let (a, b, c, d) = (*a, *b, *c, *d); + let mut tree = ferese_layout::LayoutTree::default(); + tree.insert(a, None, Axis::Horizontal, 0.5).unwrap(); + tree.insert(c, Some(a), Axis::Horizontal, 0.5).unwrap(); + tree.insert(b, Some(a), Axis::Vertical, 0.5).unwrap(); + tree.insert(d, Some(c), Axis::Vertical, 0.5).unwrap(); + state.workspaces.active_mut().layout = WorkspaceLayout::Tree(tree); + state.relayout(); + state.animations_enabled = true; + let resize = |state: &mut Ferese, id| { + let WorkspaceLayout::Tree(tree) = &mut state.workspaces.active_mut().layout else { + panic!() + }; + tree.resize_window(id, Direction::Down, 0.1).unwrap(); + state.relayout(); + state.display_handle.flush_clients().unwrap(); + }; + resize(&mut state, c); + for (_, wire) in &mut clients[2..] { + ack_configure(wire, 3); + request(wire, 2, 1, &[6, 0, 0], None); + request(wire, 2, 6, &[], None); + } + dispatch(&mut events, &mut state); + for id in [c, d] { + assert!(state.windows.transaction(&id).is_none()); + } + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(16)); + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(32)); + let right_targets = [c, d].map(|id| state.windows.geometry(&id).unwrap().logical); + resize(&mut state, a); + assert_eq!( + right_targets, + [c, d].map(|id| state.windows.geometry(&id).unwrap().logical) + ); + assert!(state.windows.transaction(&a).is_some()); + let before = [a, b, c, d].map(|id| state.windows.geometry(&id).unwrap().visual.current); + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(48)); + let after = [a, b, c, d].map(|id| state.windows.geometry(&id).unwrap().visual.current); + assert_eq!(before[..2], after[..2], "linked left reflow must wait"); + assert_ne!(before[2], after[2], "right branch must continue its existing motion"); + assert_ne!(before[3], after[3]); + let frame = state.sample_frame(&output, Duration::from_millis(16)); + assert_eq!(frame.windows[&a].geometry.visual.current, after[0]); + assert_ne!(frame.windows[&c].geometry.visual.current, after[2]); + for id in [a, c] { + let window = state.windows.window(id).unwrap(); + let geometry = state.windows.geometry(&id).unwrap(); + let location = state.space.element_location(window).unwrap(); + assert_eq!(location.x, geometry.visual.current.x.round() as i32); + assert_eq!(location.y, geometry.visual.current.y.round() as i32); + let point = (geometry.visual.current.x + 5.0, geometry.visual.current.y + 5.0).into(); + let (surface, origin) = state.window_surface_under(point).unwrap(); + assert_eq!(surface, *window.toplevel().unwrap().wl_surface()); + assert!((origin.y - geometry.visual.current.y).abs() < 0.001); + } + + // Acknowledgement does not release the left branch; one committing client + // cannot release the other tile's linked resize boundary either. + ack_configure(&mut clients[0].1, 3); + dispatch(&mut events, &mut state); + assert!(state.windows.transaction(&a).is_some()); + request(&mut clients[0].1, 2, 1, &[6, 0, 0], None); + request(&mut clients[0].1, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!(state.windows.transaction(&a).is_none()); + assert!(state.presentation_dependencies.reflow_blocked(a, &state.windows)); + assert!(state.presentation_dependencies.reflow_blocked(b, &state.windows)); + state.advance_animations_at(Duration::from_millis(284), Duration::from_millis(332)); + assert!(state.windows.transaction(&b).is_none()); + assert_eq!(state.windows.geometry(&a).unwrap().visual.current, after[0]); + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(348)); + assert_ne!(state.windows.geometry(&a).unwrap().visual.current, after[0]); + + // Removing the serial owner retires its wait, and switching layouts never + // carries a tree cohort into the new scrolling coordinate system. + resize(&mut state, a); + request(&mut clients[0].1, 2, 1, &[0, 0, 0], None); + request(&mut clients[0].1, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!(!state.presentation_dependencies.reflow_blocked(c, &state.windows)); + state + .workspaces + .set_active_layout_mode(ferese_core::LayoutMode::Scrolling, Rect::new(0.0, 0.0, 800.0, 600.0)) + .unwrap(); + state.relayout(); + for id in [c, d] { + state.windows.clear_transaction(&id); + } + state.release_presentation_dependencies(); + for id in [c, d] { + assert!(!state.presentation_dependencies.reflow_blocked(id, &state.windows)); + } +} + #[test] fn width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal() { if !crate::startup_tests::private_runtime(