diff --git a/compositor/src/input.rs b/compositor/src/input.rs index bc6547b..99540db 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -507,7 +507,11 @@ impl Ferese { .map(|workspace| workspace.id) { self.activate_managed_workspace(workspace); - } else if next { + } else if next + && self.workspaces.workspace(current).is_some_and(|workspace| { + workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty() + }) + { let next_number = self .workspaces .iter() diff --git a/compositor/src/state.rs b/compositor/src/state.rs index af40894..f0307b4 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1263,6 +1263,15 @@ impl Ferese { } pub fn relayout(&mut self) { + let protected = self + .output_workspaces + .connected_outputs() + .filter_map(|output| self.output_workspaces.active_workspace(output)) + .collect::>(); + for workspace in self.workspaces.prune_empty(&protected) { + self.output_workspaces.forget_workspace(workspace); + self.viewport_animations.remove(&workspace); + } if self.session_lock.active { self.configure_lock_surfaces(); } diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index ee0eb65..b7860ea 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -110,6 +110,37 @@ impl OutputWorkspaceMap { self.assignments.get(&workspace).copied() } + pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool { + if self + .outputs + .values() + .any(|output| output.active == workspace) + { + return false; + } + if let Some(owner) = self.assignments.remove(&workspace) + && let Some(output) = self.outputs.get_mut(&owner) + { + output.workspaces.remove(&workspace); + } + self.revisions.remove(&workspace); + self.evacuations.retain(|_, record| { + record + .workspaces + .retain(|entry| entry.workspace != workspace); + if record.active == workspace { + if let Some(entry) = record.workspaces.first() { + record.active = entry.workspace; + } else { + return false; + } + } + true + }); + debug_assert!(self.validate()); + true + } + pub fn connected_outputs(&self) -> impl Iterator + '_ { self.outputs.keys().copied() } @@ -379,6 +410,27 @@ impl OutputWorkspaceMap { #[cfg(test)] mod tests { + #[test] + fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() { + let mut outputs = super::OutputWorkspaceMap::default(); + let display = super::OutputId(1); + let geometry = super::OutputGeometry::new(0, 0, 1920, 1080); + let first = super::WorkspaceId(1); + let second = super::WorkspaceId(2); + outputs.connect(display, geometry, first).unwrap(); + assert!(!outputs.forget_workspace(first)); + outputs.switch_workspace(display, second).unwrap(); + assert!(outputs.forget_workspace(first)); + assert_eq!(outputs.output_for_workspace(first), None); + outputs.disconnect(display).unwrap(); + outputs + .connect(display, geometry, super::WorkspaceId(3)) + .unwrap(); + assert_eq!(outputs.active_workspace(display), Some(second)); + assert_eq!(outputs.output_for_workspace(first), None); + assert!(outputs.validate()); + } + #[test] fn changing_output_geometry_preserves_workspace_ownership_and_focus() { let mut outputs = super::OutputWorkspaceMap::default(); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 00dd455..d9968f2 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -523,6 +523,38 @@ impl WorkspaceSet { self.workspaces.values() } + pub fn prune_empty(&mut self, protected: &HashSet) -> Vec { + let mut empty = self + .workspaces + .values() + .filter(|workspace| { + workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty() + }) + .map(|workspace| workspace.id) + .collect::>(); + empty.sort_by_key(|id| id.0); + let visible_empty = empty + .iter() + .any(|id| *id == self.active || protected.contains(id)); + let spare = if visible_empty { + None + } else { + empty.first().copied() + }; + let mut removed = Vec::new(); + for id in empty { + if id == self.active || protected.contains(&id) || Some(id) == spare { + continue; + } + if let Some(workspace) = self.workspaces.remove(&id) { + self.names.remove(&workspace.name); + removed.push(id); + } + } + debug_assert!(self.validate().is_ok()); + removed + } + pub fn workspace_for_window(&self, window: WindowId) -> Option { self.window_workspaces.get(&window).copied() } @@ -1068,6 +1100,52 @@ fn finite_or_zero(value: f64) -> f64 { #[cfg(test)] mod tests { + #[test] + fn pruning_keeps_windows_and_one_empty_workspace() { + let mut set = super::WorkspaceSet::default(); + let first = set.active_id(); + set.insert_window(super::WindowId(1), super::Axis::Horizontal, 0.5) + .unwrap(); + let floating = set.ensure_numeric(2).unwrap(); + set.insert_floating_window( + super::WindowId(2), + floating, + super::Rect::new(0., 0., 100., 100.), + false, + ) + .unwrap(); + let spare = set.ensure_numeric(3).unwrap(); + let excess = set.ensure_numeric(4).unwrap(); + assert_eq!( + set.prune_empty(&std::collections::HashSet::new()), + vec![excess] + ); + assert!(set.workspace(first).is_some()); + assert!(set.workspace(floating).is_some()); + assert!(set.workspace(spare).is_some()); + set.activate(spare).unwrap(); + set.remove_window(super::WindowId(1)).unwrap(); + assert_eq!( + set.prune_empty(&std::collections::HashSet::new()), + vec![first] + ); + assert!(set.validate().is_ok()); + assert_ne!(set.ensure_numeric(1).unwrap(), first); + } + + #[test] + fn pruning_never_removes_another_displays_active_workspace() { + let mut set = super::WorkspaceSet::default(); + let first = set.active_id(); + let second = set.ensure_numeric(2).unwrap(); + let third = set.ensure_numeric(3).unwrap(); + assert_eq!( + set.prune_empty(&std::collections::HashSet::from([first, second])), + vec![third] + ); + assert!(set.workspace(second).is_some()); + } + #[test] fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() { let mut workspaces = WorkspaceSet::default(); diff --git a/docs/configuration.md b/docs/configuration.md index 93e5eaa..f45372b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -393,7 +393,10 @@ Settings → Notifications controls popups, Do Not Disturb, and the default time A single-output session starts with workspace 1. Numbered workspace shortcuts create workspaces on demand; switching forward past the last workspace also creates the -next one. Additional displays receive their own workspace. +next one when the current workspace contains windows. Empty workspaces are +cleaned up automatically, keeping at most one empty workspace in addition to +any empty workspace currently shown on another display. Additional displays +receive their own workspace. Shell rounding is controlled in Settings → Appearance → Shell corner radius. Small controls cap the radius to fit their size. Window rounding remains under