Preserve tree stack windows during layout conversion

This commit is contained in:
Samuel Onoja committed 2026-10-07 10:22:41 +01:00
1 parent 977f281fb2
commit 1a02267266
2 files changed
+231 -9

No files matched your search

+94 -1
View File
@@ -67,7 +67,7 @@ impl WorkspaceLayout {
pub fn preferred_window(&self) -> Option<WindowId> { pub fn preferred_window(&self) -> Option<WindowId> {
match self { match self {
Self::Scrolling(layout) => layout.active_window(), Self::Scrolling(layout) => layout.active_window(),
Self::Tree(layout) => layout.window_ids().next(), Self::Tree(layout) => layout.preferred_window(),
} }
} }
@@ -1483,6 +1483,99 @@ mod tests {
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
fn tree_stack_workspaces(ids: &[WindowId]) -> WorkspaceSet {
let mut workspaces = WorkspaceSet::new(
LayoutMode::Tree,
ColumnWidth::Proportion(0.5),
ViewportFocusStrategy::Minimal,
);
workspaces.insert_window(ids[0], Axis::Horizontal, 0.5).unwrap();
for pair in ids.windows(2) {
workspaces.insert_window(pair[1], Axis::Horizontal, 0.5).unwrap();
workspaces.stack_window(pair[1], pair[0]).unwrap();
}
workspaces
}
#[test]
fn stacked_tree_mode_conversions_preserve_membership_focus_and_order() {
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let all_ids = [WindowId(30), WindowId(10), WindowId(20), WindowId(5), WindowId(40)];
for count in [2, 3, 5] {
let ids = &all_ids[..count];
let expected = ids.iter().copied().collect::<HashSet<_>>();
for focused in ids {
// Fresh maps have independent hash seeds; structural ordering
// must give the same result for each conversion.
for _ in 0..8 {
let mut workspaces = tree_stack_workspaces(ids);
workspaces.focus_window(*focused).unwrap();
// A saved valid focus may refer to an inactive stack member.
let WorkspaceLayout::Tree(tree) = &mut workspaces.active_mut().layout else {
panic!()
};
tree.activate_window(ids[1]).unwrap();
assert_eq!(tree.geometry(bounds).unwrap().len(), 1);
assert_eq!(workspaces.active().last_focused, Some(*focused));
assert!(workspaces.validate().is_ok());
for mode in [LayoutMode::Scrolling, LayoutMode::Tree, LayoutMode::Scrolling] {
assert!(workspaces.set_active_layout_mode(mode, bounds).unwrap());
let windows = workspaces.active().layout.window_ids().collect::<Vec<_>>();
assert_eq!(windows.len(), ids.len());
assert_eq!(windows.iter().copied().collect::<HashSet<_>>(), expected);
assert_eq!(workspaces.active().last_focused, Some(*focused));
assert!(workspaces.validate().is_ok());
if let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout {
assert_eq!(windows, ids);
assert_eq!(layout.active_window(), Some(*focused));
}
}
}
}
}
}
#[test]
fn nested_tree_splits_and_stacks_survive_round_trip_conversion() {
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let mut workspaces = tree_stack_workspaces(&[WindowId(30), WindowId(10), WindowId(20)]);
workspaces.focus_window(WindowId(10)).unwrap();
workspaces.insert_window(WindowId(5), Axis::Vertical, 0.5).unwrap();
workspaces.insert_window(WindowId(40), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(50), Axis::Vertical, 0.5).unwrap();
workspaces.stack_window(WindowId(50), WindowId(40)).unwrap();
workspaces.focus_window(WindowId(40)).unwrap();
let before = workspaces.active().layout.window_ids().collect::<HashSet<_>>();
assert_eq!(before.len(), 6);
assert!(workspaces.active().layout.geometry(bounds).unwrap().len() < before.len());
for mode in [LayoutMode::Scrolling, LayoutMode::Tree, LayoutMode::Scrolling] {
assert!(workspaces.set_active_layout_mode(mode, bounds).unwrap());
let windows = workspaces.active().layout.window_ids().collect::<Vec<_>>();
assert_eq!(windows.len(), before.len());
assert_eq!(windows.into_iter().collect::<HashSet<_>>(), before);
assert_eq!(workspaces.active().last_focused, Some(WindowId(40)));
assert!(workspaces.validate().is_ok());
if let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout {
assert_eq!(layout.active_window(), Some(WindowId(40)));
}
}
}
#[test]
fn tree_preferred_window_is_deterministic_without_a_saved_focus() {
for _ in 0..32 {
let mut workspaces = tree_stack_workspaces(&[WindowId(30), WindowId(20), WindowId(40)]);
workspaces.active_mut().last_focused = None;
assert_eq!(workspaces.active().layout.preferred_window(), Some(WindowId(40)));
workspaces.insert_window(WindowId(10), Axis::Horizontal, 0.5).unwrap();
workspaces.remove_window(WindowId(10)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(40)));
assert_eq!(workspaces.active().layout.preferred_window(), Some(WindowId(40)));
assert!(workspaces.validate().is_ok());
}
}
#[test] #[test]
fn scrolling_column_commands_preserve_workspace_state() { fn scrolling_column_commands_preserve_workspace_state() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
+137 -8
View File
@@ -165,15 +165,23 @@ impl LayoutTree {
self.windows.keys().copied() self.windows.keys().copied()
} }
pub fn preferred_window(&self) -> Option<WindowId> {
let leaf = self.first_window(self.root?).ok()?;
match self.nodes.get(&leaf)? {
Node::Window(window) => Some(*window),
_ => None,
}
}
/// Include every structural leaf, even inactive stack members. Preserve
/// stack child order when their positions coincide.
pub fn window_ids_in_reading_order(&self, bounds: Rect) -> Result<Vec<WindowId>, LayoutError> { pub fn window_ids_in_reading_order(&self, bounds: Rect) -> Result<Vec<WindowId>, LayoutError> {
let geometry = self.geometry(bounds)?; self.validate()?;
let mut windows = geometry.into_iter().collect::<Vec<_>>(); let mut windows = Vec::with_capacity(self.windows.len());
windows.sort_by(|(left_window, left), (right_window, right)| { if let Some(root) = self.root {
left.x self.collect_window_positions(root, bounds, &mut windows)?;
.total_cmp(&right.x) }
.then_with(|| left.y.total_cmp(&right.y)) windows.sort_by(|(_, left), (_, right)| left.x.total_cmp(&right.x).then_with(|| left.y.total_cmp(&right.y)));
.then_with(|| left_window.0.cmp(&right_window.0))
});
Ok(windows.into_iter().map(|(window, _)| window).collect()) Ok(windows.into_iter().map(|(window, _)| window).collect())
} }
@@ -624,6 +632,34 @@ impl LayoutTree {
id id
} }
fn collect_window_positions(
&self,
node: NodeId,
bounds: Rect,
windows: &mut Vec<(WindowId, Rect)>,
) -> Result<(), LayoutError> {
match self.nodes.get(&node) {
Some(Node::Window(window)) => windows.push((*window, bounds)),
Some(Node::Split {
axis,
ratio,
first,
second,
}) => {
let (first_bounds, second_bounds) = split_rect(bounds, *axis, *ratio);
self.collect_window_positions(*first, first_bounds, windows)?;
self.collect_window_positions(*second, second_bounds, windows)?;
}
Some(Node::Stack { children, .. }) => {
for child in children {
self.collect_window_positions(*child, bounds, windows)?;
}
}
None => return Err(LayoutError::InvalidTree("node is missing")),
}
Ok(())
}
fn first_window(&self, node: NodeId) -> Result<NodeId, LayoutError> { fn first_window(&self, node: NodeId) -> Result<NodeId, LayoutError> {
match self.nodes.get(&node) { match self.nodes.get(&node) {
Some(Node::Window(_)) => Ok(node), Some(Node::Window(_)) => Ok(node),
@@ -1232,6 +1268,99 @@ mod tests {
assert!(tree.validate().is_ok()); assert!(tree.validate().is_ok());
} }
#[test]
fn structural_reading_order_includes_all_stack_members_independently_of_activation() {
let mut tree = LayoutTree::default();
let ids = [WindowId(30), WindowId(10), WindowId(20), WindowId(5)];
tree.insert(ids[0], None, Axis::Horizontal, 0.5).unwrap();
for pair in ids.windows(2) {
tree.insert(pair[1], Some(pair[0]), Axis::Horizontal, 0.5).unwrap();
tree.stack_window(pair[1], pair[0]).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
for active in ids {
tree.activate_window(active).unwrap();
assert_eq!(
tree.geometry(bounds).unwrap().keys().copied().collect::<Vec<_>>(),
vec![active]
);
assert_eq!(tree.window_ids_in_reading_order(bounds).unwrap(), ids);
assert_eq!(tree.preferred_window(), Some(active));
assert!(tree.validate().is_ok());
}
}
#[test]
fn flat_stack_reading_order_preserves_every_inactive_member() {
let mut tree = LayoutTree::default();
let ids = [WindowId(900), WindowId(7), WindowId(400), WindowId(10)];
let children = ids
.iter()
.map(|window| {
let leaf = tree.insert_node(Node::Window(*window));
tree.windows.insert(*window, leaf);
leaf
})
.collect::<Vec<_>>();
let root = tree.insert_node(Node::Stack {
children: children.clone(),
active: 2,
});
for child in children {
tree.parents.insert(child, root);
}
tree.root = Some(root);
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
assert!(tree.validate().is_ok());
assert_eq!(tree.geometry(bounds).unwrap().len(), 1);
assert_eq!(tree.preferred_window(), Some(ids[2]));
assert_eq!(tree.window_ids_in_reading_order(bounds).unwrap(), ids);
}
#[test]
fn structural_reading_order_retains_xy_order_through_splits_and_stacks() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(40), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(10), Some(WindowId(40)), Axis::Vertical, 0.5)
.unwrap();
tree.insert(WindowId(30), Some(WindowId(40)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(20), Some(WindowId(40)), Axis::Vertical, 0.5)
.unwrap();
tree.stack_window(WindowId(20), WindowId(40)).unwrap();
tree.insert(WindowId(50), Some(WindowId(10)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(25.0, 40.0, 1_000.0, 800.0);
let expected = [40, 20, 10, 30, 50].map(WindowId);
assert_eq!(tree.window_ids_in_reading_order(bounds).unwrap(), expected);
assert_eq!(tree.geometry(bounds).unwrap().len(), 4);
let collapsed = tree.window_ids_in_reading_order(Rect::default()).unwrap();
assert_eq!(collapsed.len(), expected.len());
assert_eq!(
collapsed.into_iter().collect::<HashSet<_>>(),
expected.into_iter().collect()
);
assert!(tree.validate().is_ok());
}
#[test]
fn tree_preference_follows_first_split_branch_and_active_stack_children() {
let mut tree = LayoutTree::default();
assert_eq!(tree.preferred_window(), None);
assert!(tree.window_ids_in_reading_order(Rect::default()).unwrap().is_empty());
tree.insert(WindowId(30), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(10), Some(WindowId(30)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(20), Some(WindowId(30)), Axis::Vertical, 0.5)
.unwrap();
tree.stack_window(WindowId(20), WindowId(30)).unwrap();
assert_eq!(tree.preferred_window(), Some(WindowId(20)));
tree.activate_window(WindowId(30)).unwrap();
assert_eq!(tree.preferred_window(), Some(WindowId(30)));
tree.activate_window(WindowId(10)).unwrap();
assert_eq!(tree.preferred_window(), Some(WindowId(30)));
}
#[test] #[test]
fn activating_a_hidden_stack_child_changes_visible_geometry() { fn activating_a_hidden_stack_child_changes_visible_geometry() {
let mut tree = LayoutTree::default(); let mut tree = LayoutTree::default();