fix: narrow tree resize presentation dependencies (#41)

* fix: scope tree resize waits to overlapping presentation motion

* Keep tree resize waits conservative during spring reversals
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-14 13:27:50 +01:00
1 parent a5e8ba65e2
commit 216289d66c
2 files changed
+438 -3

No files matched your search

@@ -30,6 +30,106 @@ pub(super) struct ViewportDependencies {
const MAX_VIEWPORT_STEPS: usize = 32; 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<Item = (WindowId, Rect, Rect, ferese_animation::RectVelocity)>,
inner_gap: f64,
allow_overshoot: bool,
) -> HashMap<WindowId, usize> {
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::<Vec<_>>();
// 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::<Vec<_>>();
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 /// The buffer-dependent properties in this path. Translation has its own
/// inputs; it does not inherit a tile's resize/reflow wait by membership. /// inputs; it does not inherit a tile's resize/reflow wait by membership.
#[derive(Default)] #[derive(Default)]
@@ -268,6 +368,37 @@ impl Ferese {
let mut reflow = HashMap::new(); let mut reflow = HashMap::new();
let mut viewport = HashMap::new(); let mut viewport = HashMap::new();
let mut previous_viewport = std::mem::take(&mut self.presentation_dependencies.viewport); 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::<HashMap<_, _>>();
for (&id, _) in self.windows.records() { for (&id, _) in self.windows.records() {
let Some(workspace_id) = self.workspaces.workspace_for_window(id) else { let Some(workspace_id) = self.workspaces.workspace_for_window(id) else {
continue; continue;
@@ -306,9 +437,9 @@ impl Ferese {
source == own || (source < own && transaction.width_changes()) source == own || (source < own && transaction.width_changes())
}) })
}), }),
WorkspaceLayout::Tree(_) => { WorkspaceLayout::Tree(_) => tree_groups.get(&workspace_id).is_some_and(|groups| {
workspace.layout.contains(id) && workspace.layout.contains(*slow) groups.get(&id).is_some_and(|group| groups.get(slow) == Some(group))
} }),
}); });
if linked { if linked {
waits.push(commit); waits.push(commit);
@@ -366,6 +497,193 @@ mod tests {
use super::*; use super::*;
use crate::resize_transaction::ResizeTransaction; use crate::resize_transaction::ResizeTransaction;
fn tree_reflow_groups(
motions: impl IntoIterator<Item = (WindowId, Rect, Rect)>,
inner_gap: f64,
) -> HashMap<WindowId, usize> {
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<WindowId, SizeConstraints>| {
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<WindowId, Rect>, target: &HashMap<WindowId, Rect>| {
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] #[test]
fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() { fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() {
use ferese_layout::{ColumnWidth, ScrollingLayout}; use ferese_layout::{ColumnWidth, ScrollingLayout};
@@ -31,6 +31,123 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese
(events, state, output) (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::<Vec<_>>();
let ids = clients
.iter()
.map(|(window, _)| state.windows.ids()[window])
.collect::<Vec<_>>();
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] #[test]
fn width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal() { fn width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal() {
if !crate::startup_tests::private_runtime( if !crate::startup_tests::private_runtime(