Prune unused empty workspaces while preserving visible outputs

This commit is contained in:
Samuel Onoja committed 2026-09-27 12:16:50 +01:00
1 parent 0295b5be3e
commit f87039562b
5 files changed
+148 -2

No files matched your search

+5 -1
View File
@@ -507,7 +507,11 @@ impl Ferese {
.map(|workspace| workspace.id) .map(|workspace| workspace.id)
{ {
self.activate_managed_workspace(workspace); 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 let next_number = self
.workspaces .workspaces
.iter() .iter()
+9
View File
@@ -1263,6 +1263,15 @@ impl Ferese {
} }
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
let protected = self
.output_workspaces
.connected_outputs()
.filter_map(|output| self.output_workspaces.active_workspace(output))
.collect::<HashSet<_>>();
for workspace in self.workspaces.prune_empty(&protected) {
self.output_workspaces.forget_workspace(workspace);
self.viewport_animations.remove(&workspace);
}
if self.session_lock.active { if self.session_lock.active {
self.configure_lock_surfaces(); self.configure_lock_surfaces();
} }
+52
View File
@@ -110,6 +110,37 @@ impl OutputWorkspaceMap {
self.assignments.get(&workspace).copied() 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<Item = OutputId> + '_ { pub fn connected_outputs(&self) -> impl Iterator<Item = OutputId> + '_ {
self.outputs.keys().copied() self.outputs.keys().copied()
} }
@@ -379,6 +410,27 @@ impl OutputWorkspaceMap {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn changing_output_geometry_preserves_workspace_ownership_and_focus() { fn changing_output_geometry_preserves_workspace_ownership_and_focus() {
let mut outputs = super::OutputWorkspaceMap::default(); let mut outputs = super::OutputWorkspaceMap::default();
+78
View File
@@ -523,6 +523,38 @@ impl WorkspaceSet {
self.workspaces.values() self.workspaces.values()
} }
pub fn prune_empty(&mut self, protected: &HashSet<WorkspaceId>) -> Vec<WorkspaceId> {
let mut empty = self
.workspaces
.values()
.filter(|workspace| {
workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty()
})
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
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<WorkspaceId> { pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
self.window_workspaces.get(&window).copied() self.window_workspaces.get(&window).copied()
} }
@@ -1068,6 +1100,52 @@ fn finite_or_zero(value: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() { fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
+4 -1
View File
@@ -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 A single-output session starts with workspace 1. Numbered workspace shortcuts create
workspaces on demand; switching forward past the last workspace also creates the 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. Shell rounding is controlled in Settings → Appearance → Shell corner radius.
Small controls cap the radius to fit their size. Window rounding remains under Small controls cap the radius to fit their size. Window rounding remains under