use super::{ Ferese, HashMap, HashSet, Rect, WindowId, WorkspaceId, WorkspaceLayout, window_registry, }; #[cfg(test)] use ferese_animation::SpringConfig; #[cfg(test)] use ferese_layout::Axis; #[cfg(test)] use ferese_layout::Direction; #[cfg(test)] use ferese_layout::GapConfig; #[cfg(test)] use ferese_layout::SizeConstraints; #[cfg(test)] use ferese_layout::ViewportFocusStrategy; use smithay::utils::Serial; #[derive(Clone, Copy, Debug, PartialEq)] struct ClientCommit { window: WindowId, serial: Serial, } #[derive(Clone, Copy, PartialEq)] struct ViewportCommit { commit: ClientCommit, width: (f64, f64), } #[derive(Clone, PartialEq)] struct ViewportStep { target: ferese_layout::ViewportTarget, inputs: Vec, } #[derive(Clone, Default, PartialEq)] pub(super) struct ViewportDependencies { steps: Vec, waits: Vec, // A pathological burst must not retain an unbounded history. The fallback // preserves the linked wait until commit/deadline or an absolute retarget. overflow: bool, } 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 margin = inner_gap.max(0.0) / 2.0; let axis_bounds = |position: f64, size: f64, target_position: f64, target_size: f64, speed: f64, size_speed: f64| { let distance = target_position - position; let size_distance = target_size - size; let moving = distance != 0.0 || size_distance != 0.0 || speed != 0.0 || size_speed != 0.0; let reversing = (speed != 0.0 && distance * speed <= 0.0) || (size_speed != 0.0 && size_distance * size_speed <= 0.0); // Endpoint bounds cannot contain overshoot or motion away from the // target. Widen only that axis, preserving separation on a fixed axis. if reversing || (allow_overshoot && moving) { return (f64::NEG_INFINITY, f64::INFINITY); } // Proportional component velocities preserve a shared edge. Otherwise // bound position and size separately to include intermediate excursions. let end = if (speed * size_distance - size_speed * distance).abs() < 1e-6 { (position + size).max(target_position + target_size) } else { position.max(target_position) + size.max(target_size) }; (position.min(target_position) - margin, end + margin) }; let moving = motions .into_iter() .filter(|(_, source, target, velocity)| { source != target || [velocity.x, velocity.y, velocity.width, velocity.height] .into_iter() .any(|speed| speed != 0.0) }) .map(|(id, source, target, velocity)| { let (left, right) = axis_bounds( source.x, source.width, target.x, target.width, velocity.x, velocity.width, ); let (top, bottom) = axis_bounds( source.y, source.height, target.y, target.height, velocity.y, velocity.height, ); (id, [left, top, right, bottom]) }) .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[0].max(candidate[0]) + 0.001 < bounds[2].min(candidate[2]) && bounds[1].max(candidate[1]) + 0.001 < bounds[3].min(candidate[3]) { 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)] pub(super) struct PresentationDependencies { reflow: HashMap>, // Provenance and configure serials only; ViewportPresentation owns holds. viewport: HashMap, // A reflow release tick consumes waiting wall time without stepping springs. paused_reflow: HashSet, } impl PresentationDependencies { pub(super) fn save_viewport(&self, workspace: WorkspaceId) -> Option { self.viewport.get(&workspace).cloned() } pub(super) fn restore_viewport( &mut self, workspace: WorkspaceId, saved: Option, ) { if let Some(saved) = saved { self.viewport.insert(workspace, saved); } else { self.viewport.remove(&workspace); } } #[cfg(test)] pub(super) fn wait_for_viewport( &mut self, workspace: WorkspaceId, id: WindowId, serial: Serial, ) { self.viewport.insert( workspace, ViewportDependencies { waits: vec![ClientCommit { window: id, serial }], ..Default::default() }, ); } pub(super) fn needs_tick(&self) -> bool { !self.paused_reflow.is_empty() } fn pending( wait: ClientCommit, windows: &window_registry::WindowRegistry, ) -> bool { windows .transaction(&wait.window) .is_some_and(|transaction| transaction.serial() == wait.serial) } pub(super) fn reflow_blocked( &self, id: WindowId, windows: &window_registry::WindowRegistry, ) -> bool { self.reflow .get(&id) .is_some_and(|waits| waits.iter().any(|wait| Self::pending(*wait, windows))) } pub(super) fn viewport_blocked( &self, workspace: WorkspaceId, windows: &window_registry::WindowRegistry, ) -> bool { self.viewport .get(&workspace) .is_some_and(|waits| waits.waits.iter().any(|wait| Self::pending(*wait, windows))) } pub(super) fn holds( &mut self, windows: &window_registry::WindowRegistry, ) -> HashSet { let reflow = self .reflow .keys() .copied() .filter(|id| self.reflow_blocked(*id, windows)) .collect::>(); let mut held_reflow = std::mem::replace(&mut self.paused_reflow, reflow.clone()); held_reflow.extend(reflow); self.reflow.retain(|_, waits| { waits.retain(|wait| Self::pending(*wait, windows)); !waits.is_empty() }); self.viewport.retain(|_, waits| { waits.waits.retain(|wait| Self::pending(*wait, windows)); for step in &mut waits.steps { step.inputs .retain(|input| Self::pending(input.commit, windows)); } if waits.steps.iter().all(|step| step.inputs.is_empty()) { waits.steps.clear(); } !waits.steps.is_empty() || !waits.waits.is_empty() }); held_reflow } } fn viewport_dependencies( layout: &ferese_layout::ScrollingLayout, pending: &[(WindowId, crate::resize_transaction::ResizeTransaction)], mut previous: ViewportDependencies, ) -> ViewportDependencies { let Some(target) = layout.viewport_target() else { return ViewportDependencies::default(); }; let live = |input: &ViewportCommit| { pending.iter().any(|(window, transaction)| { *window == input.commit.window && transaction.serial() == input.commit.serial }) }; for step in &mut previous.steps { for input in &mut step.inputs { if let Some((_, transaction)) = pending.iter().find(|(window, transaction)| { *window == input.commit.window && transaction.column_width() == Some(input.width) }) { input.commit.serial = transaction.serial(); } } step.inputs.retain(live); } // Fold any prefix with no current configure inputs to the numeric start // already stored in the next request. No obsolete serial owns a recipe. if let Some(first) = previous .steps .iter() .position(|step| !step.inputs.is_empty()) { previous.steps.drain(..first); } else { previous.steps.clear(); previous.overflow = false; } let inputs = pending .iter() .filter_map(|(window, transaction)| { let width = transaction.column_width()?; transaction.width_changes().then_some(ViewportCommit { commit: ClientCommit { window: *window, serial: transaction.serial(), }, width, }) }) .collect::>(); if previous .steps .last() .is_none_or(|step| step.target != *target) { if !target.uses_previous_target() { previous.steps.clear(); previous.overflow = false; } if previous.steps.len() < MAX_VIEWPORT_STEPS { previous.steps.push(ViewportStep { target: target.clone(), inputs: inputs.clone(), }); } else { previous.overflow = true; } } else if let Some(step) = previous.steps.last_mut() { // A replacement binds this request to its latest configure, including // same-width height changes. Older recipe inputs retire above. step.inputs = inputs.clone(); } let mut owners = previous .steps .iter() .flat_map(|step| step.inputs.iter().map(|input| input.commit)) .collect::>(); owners.extend(inputs.iter().map(|input| input.commit)); owners.sort_by_key(|owner| owner.window.0); owners.dedup(); let evaluate = |only: Option, exclude: Option, retained: Option<&HashSet>| { let mut start = None; for step in &previous.steps { let widths = step .inputs .iter() .filter(|input| { only.is_none_or(|only| input.commit == only) && exclude != Some(input.commit) && retained.is_none_or(|owners| owners.contains(&input.commit.window)) }) .map(|input| (input.commit.window, input.width.0, input.width.1)) .collect::>(); start = Some(step.target.with_held_widths(&widths, start)); } start.unwrap_or(layout.viewport_x()) }; let held = evaluate(None, None, None); previous.waits = owners .iter() .copied() .filter(|owner| { previous.overflow || (evaluate(Some(*owner), None, None) - layout.viewport_x()).abs() > 0.001 || (held - evaluate(None, Some(*owner), None)).abs() > 0.001 }) .collect(); // Page packing is nonlinear: all held widths can change the page even // when neither a single input nor removing one input exposes the change. // Retain a collective wait in that case, removing inputs that are not // needed to keep the held target different from the requested target. if previous.waits.is_empty() && (held - layout.viewport_x()).abs() > 0.001 { let mut collective = owners .iter() .map(|owner| owner.window) .collect::>(); for owner in &owners { collective.remove(&owner.window); if (evaluate(None, None, Some(&collective)) - layout.viewport_x()).abs() <= 0.001 { collective.insert(owner.window); } } previous.waits = owners .into_iter() .filter(|owner| collective.contains(&owner.window)) .collect(); } // Keep a bounded recipe while any of its inputs is pending: a partial // commit can expose a page-packing dependency previously masked by others. if previous.steps.iter().all(|step| step.inputs.is_empty()) { return ViewportDependencies::default(); } previous } impl Ferese { pub(super) fn release_presentation_dependencies(&mut self) { let paused_reflow = self.presentation_dependencies.paused_reflow.clone(); self.rebuild_presentation_dependencies_with_release(true); self.presentation_dependencies .paused_reflow .extend(paused_reflow); } pub(super) fn rebuild_presentation_dependencies(&mut self) { self.rebuild_presentation_dependencies_with_release(false); } fn rebuild_presentation_dependencies_with_release(&mut self, release_tick: bool) { 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; }; let Some(workspace) = self.workspaces.workspace(workspace_id) else { continue; }; let own_column = match &workspace.layout { WorkspaceLayout::Scrolling(layout) => layout .columns() .iter() .position(|column| column.windows.contains(&id)), WorkspaceLayout::Tree(_) => None, }; let mut waits = Vec::new(); for slow in self.windows.resizing() { if self.workspaces.workspace_for_window(*slow) != Some(workspace_id) { continue; } let transaction = *self.windows.transaction(slow).unwrap(); let commit = ClientCommit { window: *slow, serial: transaction.serial(), }; // Fullscreen bounds come from the output, not a column. Its // own configure still gates presentation; tiled reflow waits // apply again when the window returns to normal geometry. let linked = id == *slow || (workspace.fullscreen != Some(id) && match &workspace.layout { WorkspaceLayout::Scrolling(layout) => own_column.is_some_and(|own| { layout .columns() .iter() .position(|column| column.windows.contains(slow)) .is_some_and(|source| { source == own || (source < own && transaction.width_changes()) }) }), 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); } } if !waits.is_empty() { reflow.insert(id, waits); } } for workspace in self.workspaces.iter() { let layout = self .focus_swipe() .filter(|swipe| swipe.workspace == workspace.id) .and_then(|swipe| swipe.layout.as_ref()) .or(match &workspace.layout { WorkspaceLayout::Scrolling(layout) => Some(layout), _ => None, }); let Some(layout) = layout else { continue }; let waits = viewport_dependencies( layout, &self .windows .resizing() .filter(|id| { self.workspaces.workspace_for_window(**id) == Some(workspace.id) && layout.contains(**id) }) .map(|id| (*id, *self.windows.transaction(id).unwrap())) .collect::>(), previous_viewport.remove(&workspace.id).unwrap_or_default(), ); if !waits.steps.is_empty() || !waits.waits.is_empty() { viewport.insert(workspace.id, waits); } } // A retarget uses the new dependency set immediately. A dependency that // disappeared must not freeze a newly independent target for one frame. self.presentation_dependencies .paused_reflow .retain(|id| reflow.contains_key(id)); self.presentation_dependencies.reflow = reflow; self.presentation_dependencies.viewport = viewport; for (workspace, presentation) in &mut self.viewports { presentation.reconcile_hold( self.presentation_dependencies .viewport_blocked(*workspace, &self.windows), release_tick, ); } } } #[cfg(test)] mod tests { use super::*; use crate::resize_transaction::ResizeTransaction; use crate::state::ViewportPresentation; use std::time::Duration; 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 reversed_or_overshooting_vertical_motion_preserves_horizontal_separation() { for allow_overshoot in [false, true] { let groups = super::tree_reflow_groups( [ ( WindowId(1), Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0), ferese_animation::RectVelocity { y: -20.0, ..Default::default() }, ), ( WindowId(2), Rect::new(0.0, 110.0, 100.0, 100.0), Rect::new(0.0, 130.0, 100.0, 100.0), Default::default(), ), ( WindowId(3), Rect::new(110.0, 0.0, 100.0, 100.0), Rect::new(110.0, 20.0, 100.0, 100.0), Default::default(), ), ], 10.0, allow_overshoot, ); assert_eq!(groups[&WindowId(1)], groups[&WindowId(2)]); assert_ne!(groups[&WindowId(1)], groups[&WindowId(3)]); } } #[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}; let bounds = Rect::new(0.0, 0.0, 200.0, 100.0); let gaps = GapConfig { inner: 0.0, outer: 0.0, smart: false, }; let constraints = HashMap::new(); let a = WindowId(1); let b = WindowId(2); let c = WindowId(3); let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0)); layout.set_focus_strategy(ViewportFocusStrategy::Center); layout.insert(a, None).unwrap(); layout.insert(b, Some(a)).unwrap(); layout .set_column_width(a, ColumnWidth::Fixed(150.0)) .unwrap(); layout .set_column_width(b, ColumnWidth::Fixed(300.0)) .unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &constraints, Some(b)) .unwrap(); let pending = [( a, ResizeTransaction::new(10.into(), Duration::ZERO) .with_column_width(Some(100.0), Some(150.0)), )]; let first = viewport_dependencies(&layout, &pending, Default::default()); assert_eq!(first.waits.len(), 1); layout.slide_focus_from(b, a).unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &constraints, Some(a)) .unwrap(); assert_eq!(layout.viewport_x(), 50.0); let canceled = viewport_dependencies(&layout, &pending, first); assert!( canceled.waits.is_empty(), "relative target cancels pending prefix width" ); let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(40.0)); layout.set_focus_strategy(ViewportFocusStrategy::Paged); layout.insert(a, None).unwrap(); layout.insert(b, Some(a)).unwrap(); layout.insert(c, Some(b)).unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &constraints, Some(c)) .unwrap(); let pending = [ ( a, ResizeTransaction::new(10.into(), Duration::ZERO) .with_column_width(Some(100.0), Some(40.0)), ), ( b, ResizeTransaction::new(20.into(), Duration::ZERO) .with_column_width(Some(100.0), Some(40.0)), ), ]; assert!(!layout.viewport_depends_on_width(a, 100.0, 40.0)); assert!(!layout.viewport_depends_on_width(b, 100.0, 40.0)); let both = viewport_dependencies(&layout, &pending, Default::default()); assert_eq!(both.waits.len(), 2, "joint held widths select another page"); let partial = viewport_dependencies(&layout, &pending[1..], both); assert!( partial.waits.is_empty(), "one remaining held width fits this page" ); assert!( viewport_dependencies(&layout, &[], partial) .steps .is_empty() ); // Independent absolute requests reset history. Relative request storage // is capped even if a client keeps replacing its configure. layout.set_focus_strategy(ViewportFocusStrategy::Center); layout .set_column_width(a, ColumnWidth::Fixed(150.0)) .unwrap(); layout .set_column_width(b, ColumnWidth::Fixed(300.0)) .unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &constraints, Some(b)) .unwrap(); let pending = [( a, ResizeTransaction::new(30.into(), Duration::ZERO) .with_column_width(Some(100.0), Some(150.0)), )]; let mut graph = viewport_dependencies(&layout, &pending, Default::default()); for index in 0..80 { let (from, to) = if index % 2 == 0 { (b, a) } else { (a, b) }; layout.slide_focus_from(from, to).unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &constraints, Some(to)) .unwrap(); graph = viewport_dependencies(&layout, &pending, graph); assert!(graph.steps.len() <= MAX_VIEWPORT_STEPS); } assert!(graph.overflow); assert!( !graph.waits.is_empty(), "bounded fallback must not free linked input" ); layout.center_window(a, bounds, gaps, &constraints).unwrap(); graph = viewport_dependencies(&layout, &pending, graph); assert_eq!(graph.steps.len(), 1); assert!(!graph.overflow); } #[test] fn properties_wait_for_only_current_configures_and_consume_release_time() { let mut windows = window_registry::WindowRegistry::default(); let a = WindowId(1); let b = WindowId(2); let neighbor = WindowId(3); windows.register("a", a); windows.register("b", b); windows.register("neighbor", neighbor); windows.set_transaction(a, ResizeTransaction::new(10.into(), Duration::ZERO)); windows.set_transaction( b, ResizeTransaction::new(20.into(), Duration::from_millis(100)), ); let commit = |window, serial| ClientCommit { window, serial: Serial::from(serial), }; let workspace = WorkspaceId(1); let mut dependencies = PresentationDependencies::default(); dependencies.reflow.insert(a, vec![commit(a, 10)]); dependencies .reflow .insert(neighbor, vec![commit(a, 10), commit(b, 20)]); dependencies.wait_for_viewport(workspace, b, 20.into()); assert!(dependencies.reflow_blocked(neighbor, &windows)); assert!(!dependencies.reflow_blocked(b, &windows)); assert!(dependencies.viewport_blocked(workspace, &windows)); dependencies.holds(&windows); // A replacement cannot be released by the older committed serial. windows.set_transaction( b, ResizeTransaction::new(21.into(), Duration::from_millis(150)), ); assert!(!windows.transaction(&b).unwrap().accepts(Some(20.into()))); assert!(!dependencies.viewport_blocked(workspace, &windows)); dependencies.wait_for_viewport(workspace, b, 21.into()); dependencies .reflow .insert(neighbor, vec![commit(a, 10), commit(b, 21)]); windows.expire_transactions(Duration::from_millis(300)); assert!(windows.transaction(&a).is_none()); assert!(windows.transaction(&b).is_some()); assert!(dependencies.reflow_blocked(neighbor, &windows)); let held = dependencies.holds(&windows); assert!( held.contains(&a), "release tick must not charge waiting time" ); let held = dependencies.holds(&windows); assert!(!held.contains(&a)); // Unmap removes the serial owner; both properties retire on the tick. windows.remove(&"b"); assert!(!dependencies.viewport_blocked(workspace, &windows)); dependencies.holds(&windows); dependencies.holds(&windows); assert!(!dependencies.needs_tick()); assert!(dependencies.reflow.is_empty() && dependencies.viewport.is_empty()); } #[test] fn collective_page_waits_survive_redundant_pending_widths() { use ferese_layout::{ColumnWidth, ScrollingLayout}; let bounds = Rect::new(0.0, 0.0, 200.0, 100.0); let gaps = GapConfig { inner: 0.0, outer: 0.0, smart: false, }; let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(40.0)); layout.set_focus_strategy(ViewportFocusStrategy::Paged); let ids = [ WindowId(1), WindowId(2), WindowId(3), WindowId(4), WindowId(5), WindowId(6), ]; for (i, id) in ids.iter().enumerate() { layout .insert(*id, i.checked_sub(1).map(|j| ids[j])) .unwrap(); } for (id, width) in ids.into_iter().zip([40.0, 40.0, 80.0, 80.0, 40.0, 40.0]) { layout .set_column_width(id, ColumnWidth::Fixed(width)) .unwrap(); } layout.focus(ids[4]).unwrap(); layout .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(ids[4])) .unwrap(); assert_eq!(layout.viewport_x(), 160.0); let widths = [ (ids[0], 120.0, 40.0), (ids[1], 120.0, 40.0), (ids[4], 80.0, 40.0), (ids[5], 160.0, 40.0), ]; let pending = widths .into_iter() .enumerate() .map(|(i, (id, source, target))| { ( id, ResizeTransaction::new((i as u32 + 10).into(), Duration::ZERO) .with_column_width(Some(source), Some(target)), ) }) .collect::>(); assert_eq!( layout .viewport_target() .unwrap() .with_held_widths(&widths, None), 320.0 ); let graph = viewport_dependencies(&layout, &pending, Default::default()); assert!( !graph.waits.is_empty(), "the collectively different page needs a commit wait" ); assert!( !graph.waits.iter().any(|wait| wait.window == ids[5]), "a column after focus cannot own this page wait" ); let reversed = pending.iter().copied().rev().collect::>(); assert_eq!( viewport_dependencies(&layout, &reversed, Default::default()).waits, graph.waits ); // Exercise every partial-commit order, including a redundant owner // committing before an owner in the retained collective group. for mask in 0..1 << pending.len() { let remaining = pending .iter() .enumerate() .filter(|(index, _)| mask & (1 << index) != 0) .map(|(_, input)| *input) .collect::>(); let held_widths = remaining .iter() .map(|(id, transaction)| { let (source, target) = transaction.column_width().unwrap(); (*id, source, target) }) .collect::>(); let held = layout .viewport_target() .unwrap() .with_held_widths(&held_widths, None); let partial = viewport_dependencies(&layout, &remaining, graph.clone()); if (held - layout.viewport_x()).abs() > 0.001 { assert!( !partial.waits.is_empty(), "collective wait lost after partial commits: {mask}" ); } assert!( !partial.waits.iter().any(|wait| wait.window == ids[5]), "unrelated column blocked mask {mask}" ); if remaining.is_empty() { assert!(partial.steps.is_empty() && partial.waits.is_empty()); } // Feed each precise partial-commit result through the presentation // owner. Remaining unrelated configures must not keep it held. let mut windows = window_registry::WindowRegistry::default(); for id in ids { windows.register(id, id); } for (id, transaction) in &remaining { windows.set_transaction(*id, *transaction); } let workspace = WorkspaceId(1); let mut dependencies = PresentationDependencies::default(); dependencies.viewport.insert(workspace, partial); let blocked = dependencies.viewport_blocked(workspace, &windows); let mut viewport = ViewportPresentation::new(0.0); viewport.retarget(layout.viewport_x(), true); viewport.reconcile_hold(true, false); viewport.reconcile_hold(blocked, true); viewport.advance( Duration::from_secs(3), SpringConfig::default(), true, blocked, ); assert_eq!( viewport.motion().current, 0.0, "release consumed waiting time: {mask}" ); viewport.advance( Duration::from_millis(16), SpringConfig::default(), true, blocked, ); assert_eq!( viewport.motion().current == 0.0, blocked, "incorrect held owner: {mask}" ); } } }