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:
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;
|
||||
|
||||
/// 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
|
||||
/// 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::<HashMap<_, _>>();
|
||||
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<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]
|
||||
fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() {
|
||||
use ferese_layout::{ColumnWidth, ScrollingLayout};
|
||||
|
||||
@@ -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::<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]
|
||||
fn width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
|
||||
Reference in new issue
Block a user