feat: add invariant-safe window stacks
This commit is contained in:
1 parent
6060257ffe
commit
da4aaf9fb0
3 files changed
+327
-15
No files matched your search
@@ -262,6 +262,9 @@ impl Ferese {
|
|||||||
.filter(|(_, id)| {
|
.filter(|(_, id)| {
|
||||||
self.workspaces.workspace_for_window(**id) != Some(active)
|
self.workspaces.workspace_for_window(**id) != Some(active)
|
||||||
|| fullscreen.is_some_and(|fullscreen| fullscreen != **id)
|
|| fullscreen.is_some_and(|fullscreen| fullscreen != **id)
|
||||||
|
|| (fullscreen.is_none()
|
||||||
|
&& self.workspaces.placement(**id) == Some(WindowPlacement::Tiled)
|
||||||
|
&& !tiled_geometry.contains_key(id))
|
||||||
})
|
})
|
||||||
.map(|(window, _)| window.clone())
|
.map(|(window, _)| window.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -165,12 +165,46 @@ impl WorkspaceSet {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.placement(window) == Some(WindowPlacement::Tiled) {
|
||||||
|
self.active_mut().layout.activate_window(window)?;
|
||||||
|
}
|
||||||
self.active_mut().last_focused = Some(window);
|
self.active_mut().last_focused = Some(window);
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stack_window(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
target: WindowId,
|
||||||
|
) -> Result<(), WorkspaceError> {
|
||||||
|
let workspace = self
|
||||||
|
.workspace_for_window(window)
|
||||||
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
|
||||||
|
if self.workspace_for_window(target) != Some(workspace) {
|
||||||
|
return Err(WorkspaceError::InvalidState(
|
||||||
|
"stack windows belong to different workspaces",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.placement(window) != Some(WindowPlacement::Tiled)
|
||||||
|
|| self.placement(target) != Some(WindowPlacement::Tiled)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceError::InvalidState("stack window is not tiled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace = self
|
||||||
|
.workspaces
|
||||||
|
.get_mut(&workspace)
|
||||||
|
.ok_or(WorkspaceError::InvalidState("window workspace is missing"))?;
|
||||||
|
workspace.layout.stack_window(window, target)?;
|
||||||
|
workspace.last_focused = Some(window);
|
||||||
|
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
|
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
return Err(WorkspaceError::InvalidNumericName(index));
|
return Err(WorkspaceError::InvalidNumericName(index));
|
||||||
@@ -796,4 +830,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(workspaces.validate().is_ok());
|
assert!(workspaces.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacking_tiled_windows_preserves_workspace_membership() {
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
workspaces
|
||||||
|
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
workspaces
|
||||||
|
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
workspaces.stack_window(WindowId(2), WindowId(1)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
|
||||||
|
assert_eq!(
|
||||||
|
workspaces
|
||||||
|
.active()
|
||||||
|
.layout
|
||||||
|
.geometry(Rect::new(0.0, 0.0, 100.0, 80.0))
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(workspaces.validate().is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+266
-15
@@ -178,30 +178,120 @@ impl LayoutTree {
|
|||||||
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
||||||
let leaf = self
|
let leaf = self
|
||||||
.windows
|
.windows
|
||||||
.remove(&window)
|
.get(&window)
|
||||||
|
.copied()
|
||||||
.ok_or(LayoutError::UnknownWindow(window))?;
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
let Some(parent) = self.parents.remove(&leaf) else {
|
let Some(parent) = self.parents.get(&leaf).copied() else {
|
||||||
|
self.windows.remove(&window);
|
||||||
self.nodes.remove(&leaf);
|
self.nodes.remove(&leaf);
|
||||||
self.root = None;
|
self.root = None;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let sibling = match self.nodes.get(&parent) {
|
match self.nodes.get(&parent).cloned() {
|
||||||
Some(Node::Split { first, second, .. }) if *first == leaf => *second,
|
Some(Node::Split { first, second, .. }) => {
|
||||||
Some(Node::Split { first, second, .. }) if *second == leaf => *first,
|
let sibling = if first == leaf {
|
||||||
_ => return Err(LayoutError::InvalidTree("window parent is not a split")),
|
second
|
||||||
};
|
} else if second == leaf {
|
||||||
let grandparent = self.parents.remove(&parent);
|
first
|
||||||
|
} else {
|
||||||
|
return Err(LayoutError::InvalidTree("split does not contain window"));
|
||||||
|
};
|
||||||
|
|
||||||
self.nodes.remove(&leaf);
|
self.collapse_parent(parent, leaf, sibling)?;
|
||||||
self.nodes.remove(&parent);
|
}
|
||||||
|
Some(Node::Stack {
|
||||||
|
mut children,
|
||||||
|
mut active,
|
||||||
|
}) => {
|
||||||
|
let position = children
|
||||||
|
.iter()
|
||||||
|
.position(|child| *child == leaf)
|
||||||
|
.ok_or(LayoutError::InvalidTree("stack does not contain window"))?;
|
||||||
|
|
||||||
if let Some(grandparent) = grandparent {
|
if children.len() == 2 {
|
||||||
self.replace_child(grandparent, parent, sibling)?;
|
let sibling = children[1 - position];
|
||||||
self.parents.insert(sibling, grandparent);
|
self.collapse_parent(parent, leaf, sibling)?;
|
||||||
|
} else {
|
||||||
|
children.remove(position);
|
||||||
|
if position < active {
|
||||||
|
active -= 1;
|
||||||
|
} else if position == active {
|
||||||
|
active = active.min(children.len() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.windows.remove(&window);
|
||||||
|
self.parents.remove(&leaf);
|
||||||
|
self.nodes.remove(&leaf);
|
||||||
|
self.nodes.insert(parent, Node::Stack { children, active });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Node::Window(_)) => {
|
||||||
|
return Err(LayoutError::InvalidTree("window parent is a window"));
|
||||||
|
}
|
||||||
|
None => return Err(LayoutError::InvalidTree("window parent is missing")),
|
||||||
|
}
|
||||||
|
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
|
||||||
|
if window == target {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !self.contains(target) {
|
||||||
|
return Err(LayoutError::UnknownWindow(target));
|
||||||
|
}
|
||||||
|
if !self.contains(window) {
|
||||||
|
return Err(LayoutError::UnknownWindow(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.remove(window)?;
|
||||||
|
|
||||||
|
let target_node = self
|
||||||
|
.windows
|
||||||
|
.get(&target)
|
||||||
|
.copied()
|
||||||
|
.ok_or(LayoutError::UnknownWindow(target))?;
|
||||||
|
let old_parent = self.parents.get(&target_node).copied();
|
||||||
|
let window_node = self.insert_node(Node::Window(window));
|
||||||
|
let stack = self.insert_node(Node::Stack {
|
||||||
|
children: vec![target_node, window_node],
|
||||||
|
active: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.windows.insert(window, window_node);
|
||||||
|
self.parents.insert(target_node, stack);
|
||||||
|
self.parents.insert(window_node, stack);
|
||||||
|
|
||||||
|
if let Some(parent) = old_parent {
|
||||||
|
self.replace_child(parent, target_node, stack)?;
|
||||||
|
self.parents.insert(stack, parent);
|
||||||
} else {
|
} else {
|
||||||
self.parents.remove(&sibling);
|
self.root = Some(stack);
|
||||||
self.root = Some(sibling);
|
}
|
||||||
|
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
||||||
|
let mut child = self
|
||||||
|
.windows
|
||||||
|
.get(&window)
|
||||||
|
.copied()
|
||||||
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
|
||||||
|
while let Some(parent) = self.parents.get(&child).copied() {
|
||||||
|
if let Some(Node::Stack { children, active }) = self.nodes.get_mut(&parent) {
|
||||||
|
*active = children
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == child)
|
||||||
|
.ok_or(LayoutError::InvalidTree("stack does not contain child"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
child = parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
@@ -514,6 +604,35 @@ impl LayoutTree {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collapse_parent(
|
||||||
|
&mut self,
|
||||||
|
parent: NodeId,
|
||||||
|
leaf: NodeId,
|
||||||
|
sibling: NodeId,
|
||||||
|
) -> Result<(), LayoutError> {
|
||||||
|
let window = match self.nodes.get(&leaf) {
|
||||||
|
Some(Node::Window(window)) => *window,
|
||||||
|
_ => return Err(LayoutError::InvalidTree("removed leaf is not a window")),
|
||||||
|
};
|
||||||
|
let grandparent = self.parents.get(&parent).copied();
|
||||||
|
|
||||||
|
self.windows.remove(&window);
|
||||||
|
self.parents.remove(&leaf);
|
||||||
|
self.parents.remove(&parent);
|
||||||
|
self.nodes.remove(&leaf);
|
||||||
|
self.nodes.remove(&parent);
|
||||||
|
|
||||||
|
if let Some(grandparent) = grandparent {
|
||||||
|
self.replace_child(grandparent, parent, sibling)?;
|
||||||
|
self.parents.insert(sibling, grandparent);
|
||||||
|
} else {
|
||||||
|
self.parents.remove(&sibling);
|
||||||
|
self.root = Some(sibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn layout_node(
|
fn layout_node(
|
||||||
&self,
|
&self,
|
||||||
node: NodeId,
|
node: NodeId,
|
||||||
@@ -920,4 +1039,136 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacking_windows_shows_only_the_active_child() {
|
||||||
|
let mut tree = LayoutTree::default();
|
||||||
|
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
|
||||||
|
|
||||||
|
let geometry = tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0)).unwrap();
|
||||||
|
assert_eq!(geometry.len(), 1);
|
||||||
|
assert_eq!(geometry[&WindowId(2)], Rect::new(0.0, 0.0, 100.0, 80.0));
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activating_a_hidden_stack_child_changes_visible_geometry() {
|
||||||
|
let mut tree = LayoutTree::default();
|
||||||
|
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
|
||||||
|
tree.activate_window(WindowId(1)).unwrap();
|
||||||
|
|
||||||
|
let geometry = tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0)).unwrap();
|
||||||
|
assert_eq!(geometry.len(), 1);
|
||||||
|
assert_eq!(geometry[&WindowId(1)], Rect::new(0.0, 0.0, 100.0, 80.0));
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_from_a_stack_collapses_or_preserves_cardinality() {
|
||||||
|
let mut tree = LayoutTree::default();
|
||||||
|
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
|
||||||
|
tree.insert(WindowId(3), Some(WindowId(2)), Axis::Vertical, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tree.remove(WindowId(3)).unwrap();
|
||||||
|
tree.remove(WindowId(2)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tree.node(tree.root().unwrap()),
|
||||||
|
Some(&Node::Window(WindowId(1)))
|
||||||
|
);
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn randomized_operation_sequences_preserve_tree_invariants() {
|
||||||
|
let mut tree = LayoutTree::default();
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
let mut next_window = 1;
|
||||||
|
let mut random = 0x5eed_u64;
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_920.0, 1_080.0);
|
||||||
|
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
random = random
|
||||||
|
.wrapping_mul(6_364_136_223_846_793_005)
|
||||||
|
.wrapping_add(1);
|
||||||
|
let operation = random % 6;
|
||||||
|
|
||||||
|
match operation {
|
||||||
|
0 if windows.len() < 32 => {
|
||||||
|
let window = WindowId(next_window);
|
||||||
|
next_window += 1;
|
||||||
|
let focused = choose_window(&windows, random.rotate_left(7));
|
||||||
|
let axis = if random & 1 == 0 {
|
||||||
|
Axis::Horizontal
|
||||||
|
} else {
|
||||||
|
Axis::Vertical
|
||||||
|
};
|
||||||
|
|
||||||
|
tree.insert(window, focused, axis, ratio_from_random(random))
|
||||||
|
.unwrap();
|
||||||
|
windows.push(window);
|
||||||
|
}
|
||||||
|
1 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
let window = windows.swap_remove(index);
|
||||||
|
tree.remove(window).unwrap();
|
||||||
|
}
|
||||||
|
2 if windows.len() >= 2 => {
|
||||||
|
let first = random as usize % windows.len();
|
||||||
|
let mut second = random.rotate_left(13) as usize % windows.len();
|
||||||
|
if first == second {
|
||||||
|
second = (second + 1) % windows.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.stack_window(windows[first], windows[second]).unwrap();
|
||||||
|
}
|
||||||
|
3 if !windows.is_empty() => {
|
||||||
|
let window = windows[random as usize % windows.len()];
|
||||||
|
tree.activate_window(window).unwrap();
|
||||||
|
}
|
||||||
|
4 if !windows.is_empty() => {
|
||||||
|
let window = windows[random as usize % windows.len()];
|
||||||
|
let _ = tree.move_window(window, random_direction(random), bounds);
|
||||||
|
}
|
||||||
|
5 if !windows.is_empty() => {
|
||||||
|
let window = windows[random as usize % windows.len()];
|
||||||
|
tree.resize_window(window, random_direction(random), 0.03)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_window(windows: &[WindowId], random: u64) -> Option<WindowId> {
|
||||||
|
(!windows.is_empty()).then(|| windows[random as usize % windows.len()])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ratio_from_random(random: u64) -> f64 {
|
||||||
|
0.2 + (random.rotate_left(19) % 601) as f64 / 1_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_direction(random: u64) -> Direction {
|
||||||
|
match random.rotate_left(29) % 4 {
|
||||||
|
0 => Direction::Left,
|
||||||
|
1 => Direction::Right,
|
||||||
|
2 => Direction::Up,
|
||||||
|
_ => Direction::Down,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user